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-19 — 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`** — the counter this
95    /// doc used to name has a producer only under [`Overflow::DropTail`],
96    /// so under the default [`Overflow::Block`] it is permanently zero and
97    /// an author following the instruction would read a zero and conclude
98    /// their rule had fired. `objects_delivered` has a producer under every
99    /// overflow policy. (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-19 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 a session driven without a proxy
255    /// behaves exactly as it did before this field existed.
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 made it depend on exactly that. A catch-all
429    /// placed first — which is the value `ClassRule::default()` hands you,
430    /// since `Matcher::default()` claims everything — matched every unit and
431    /// so no later rule was ever examined, silencing every diagnostic behind
432    /// it for the session's whole life. Measured: same profile, same
433    /// traffic, class order reversed, 1 report against 0.
434    ///
435    /// The winner itself is skipped, and that is not the same thing: a rule
436    /// that *matched* cannot have been defeated by an absent key, so
437    /// reporting it would be a false positive on a working rule.
438    ///
439    /// The cost is one `Matcher::unmatchable_fields` per rule per unit —
440    /// three `is_some` tests over a fixed-size array, no allocation, and the
441    /// relaxed `fetch_or` is reached only when a field is genuinely absent.
442    /// A profile whose keys are all carried never touches an atomic.
443    pub(crate) fn classify(
444        &self,
445        side: ProxySide,
446        meta: &ObjectMeta,
447        unit_index: u64,
448        mut report: impl FnMut(usize, MatcherField),
449    ) -> Class {
450        let mut claimed: Option<Class> = None;
451        for (index, rule) in self.profile.classes().iter().enumerate() {
452            if claimed.is_none() && rule.matcher.matches(side, meta, unit_index) {
453                claimed = Some(Class::Rule(index));
454                continue;
455            }
456            for field in rule.matcher.unmatchable_fields(meta).into_iter().flatten() {
457                self.report_once(index, field, &mut report);
458            }
459        }
460        claimed.unwrap_or(Class::Default)
461    }
462
463    /// [`Self::classify`] for a datagram.
464    ///
465    /// The same two-part sweep — first match wins, every rule that did not
466    /// match is asked whether it *could* have — over
467    /// [`Matcher::matches_datagram`] and
468    /// [`Matcher::unmatchable_fields_datagram`] instead of their framed
469    /// siblings. The report-once mask is the one `classify` uses, so a
470    /// session carrying both never reports a `(class, field)` pair twice and
471    /// whichever carrier arrives first is the one that says it.
472    ///
473    /// `unit_index` counts hook-visible datagrams per forwarding direction;
474    /// see [`Matcher::matches_datagram`] for why that is the only scope a
475    /// datagram has.
476    ///
477    /// The draft is taken as an argument rather than read off the unit
478    /// because an [`AnyDatagramMeta`] does not carry one — it is the resolved
479    /// identity, not the header — where an [`ObjectMeta`] does.
480    pub(crate) fn classify_datagram(
481        &self,
482        side: ProxySide,
483        draft: DraftVersion,
484        meta: &AnyDatagramMeta,
485        unit_index: u64,
486        mut report: impl FnMut(usize, MatcherField),
487    ) -> Class {
488        let mut claimed: Option<Class> = None;
489        for (index, rule) in self.profile.classes().iter().enumerate() {
490            if claimed.is_none() && rule.matcher.matches_datagram(side, meta, unit_index) {
491                claimed = Some(Class::Rule(index));
492                continue;
493            }
494            for field in rule.matcher.unmatchable_fields_datagram(draft, meta).into_iter().flatten()
495            {
496                self.report_once(index, field, &mut report);
497            }
498        }
499        claimed.unwrap_or(Class::Default)
500    }
501
502    /// Set `(index, field)`'s bit and call `report` only if this is the
503    /// first time anyone has.
504    ///
505    /// One relaxed `fetch_or`; see [`Self::unmatchable`] for why the losing
506    /// side of a race costs one duplicate event rather than a lock.
507    fn report_once(
508        &self,
509        index: usize,
510        field: MatcherField,
511        report: &mut impl FnMut(usize, MatcherField),
512    ) {
513        let Some(mask) = self.unmatchable.get(index) else { return };
514        if mask.fetch_or(field.bit(), Ordering::Relaxed) & field.bit() == 0 {
515            report(index, field);
516        }
517    }
518
519    /// Whether a unit of `bytes` fits behind `queued_objects` units holding
520    /// `queued_bytes`, and what to do if it does not.
521    ///
522    /// The byte test counts the arriving unit (`queued_bytes + bytes >
523    /// depth_bytes`) and the object test does not (`queued_objects >=
524    /// depth_objects`), because they answer different questions: the byte
525    /// depth is a budget the queue may not exceed, and the object depth is
526    /// a count of slots, all of which are taken.
527    ///
528    /// [`Overflow::Block`] answers [`Admission::Admit`] here and always
529    /// will: under `Block` a full queue is one the read branch is not
530    /// polling, so a unit reaching this function proves there was room for
531    /// it. Deciding to drop it here as well would double-count the same
532    /// limit and silently make `Block` destructive.
533    pub(crate) fn admit(
534        &self,
535        bytes: usize,
536        queued_bytes: usize,
537        queued_objects: usize,
538    ) -> Admission {
539        let queue = self.profile.queue();
540        let fits = queued_objects < queue.depth_objects
541            && queued_bytes.saturating_add(bytes) <= queue.depth_bytes;
542        if fits {
543            return Admission::Admit;
544        }
545        match queue.overflow {
546            Overflow::Block => Admission::Admit,
547            Overflow::DropTail => Admission::DropTail,
548            Overflow::ResetStream { code } => Admission::ResetStream { code },
549        }
550    }
551
552    // ── release ─────────────────────────────────────────────────────
553
554    /// Ask whether a queued unit of `class` holding `bytes` may go at `now`,
555    /// debiting its bucket when the answer is yes.
556    ///
557    /// **The release seam.** Called from `PendingQueue::pop_next_due` and
558    /// from nowhere else, exactly once per released unit.
559    /// [`Class::Default`] and [`Class::Unshapeable`] answer [`Acquire::Now`]
560    /// unconditionally and touch no lock: neither names a bucket, so there is
561    /// nothing to charge and nothing to arbitrate. That is not a shortcut — it
562    /// is what *no rule claimed this* means. A profile that wants a catch-all
563    /// charges one by writing a class with an all-`None`
564    /// [`Matcher`](super::Matcher).
565    ///
566    /// The discipline is consulted **before** the bucket, and the order is
567    /// load-bearing: charging first would let a class the discipline is
568    /// holding back spend tokens the class ahead of it is entitled to, and
569    /// the debit is not refundable.
570    ///
571    /// # While pacing is switched off
572    ///
573    /// Every unit is granted here, before the bucket is read and before the
574    /// discipline is consulted, so nothing is debited and no class is parked
575    /// while the switch is clear.
576    ///
577    /// **It is read when a queue asks, and a queue asks when its head's park
578    /// expires.** This function is the whole of the release decision, and a
579    /// queue that has been refused here parks its head on the deadline the
580    /// refusal named. Switching pacing off does not reach into that park; it
581    /// changes the answer the *next* call gives. So the delay between the
582    /// switch and a stream resuming is the park that was already running,
583    /// and which park that is depends on why the head was refused:
584    ///
585    /// * refused by a bucket that will refill — the refill instant, which is
586    ///   one unit's worth of the configured rate;
587    /// * refused by a bucket that never refills, or one whose burst cannot
588    ///   cover a unit — no instant exists, so the park is the queue's
589    ///   `max_hold` clamp, which on a stopped class is the whole of it;
590    /// * held back by the discipline — the class ahead withdrawing its
591    ///   demand, which is now immediate, because that class is granted here
592    ///   too.
593    ///
594    /// The middle case is the one to know: switching pacing off does **not**
595    /// promptly release a class configured at zero. Nothing here can, and
596    /// nothing else in this crate can either — the park is a timer a queue
597    /// armed, the queues are per stream, and no session-wide wake reaches
598    /// them.
599    ///
600    /// Nothing leaves a bucket half-charged whichever way the switch moves:
601    /// the debit happens on the same call as the grant or not at all.
602    pub(crate) fn acquire(&self, class: Class, bytes: u64, now: Instant) -> Acquire {
603        if !self.enabled.load(Ordering::Relaxed) {
604            return Acquire::Now;
605        }
606        let Class::Rule(index) = class else {
607            return Acquire::Now;
608        };
609        let Some(&bucket) = self.class_bucket.get(index) else {
610            return Acquire::Now;
611        };
612        let mut state = self.state.lock().expect("shape scheduler");
613
614        if let Some(gate) = self.discipline_holds(&mut state, index, bucket) {
615            return Acquire::Starved(gate);
616        }
617
618        let config = &self.profile.buckets()[bucket];
619        match charge(&mut state.buckets[bucket], config.rate_bps, config.burst_bytes, bytes, now) {
620            Grant::Now => {
621                if self.profile.discipline() == Discipline::WeightedRoundRobin {
622                    let credit = &mut state.credits[index];
623                    *credit = credit.saturating_sub(1);
624                }
625                Acquire::Now
626            }
627            Grant::Later(at) => Acquire::Later(at),
628            Grant::Never => Acquire::Never,
629            Grant::LargerThanBurst { burst_bytes, unit_bytes } => {
630                Acquire::LargerThanBurst { burst_bytes, unit_bytes }
631            }
632        }
633    }
634
635    /// Claim the once-per-session right to report that `class`'s burst is
636    /// smaller than one of its own units, and say what to call the class.
637    ///
638    /// `Some(name)` for the first caller and `None` for every one after, so
639    /// a class whose every unit is refused for the whole session reports
640    /// once rather than once per release attempt. The name comes back with
641    /// the claim because the caller needs both and asking twice would let a
642    /// caller claim one class and label another.
643    ///
644    /// **Once per session per class, not once per stream.** A burst that
645    /// cannot cover an object is a property of the profile, so every stream
646    /// carrying that class reproduces it and a per-stream report would say
647    /// the same thing as many times as the session has streams. The rows
648    /// that keep counting are the class's own `tokens_exhausted_episodes`
649    /// and the `HoldClamped` report on each clamped unit.
650    ///
651    /// Answers `None` for the two rows that name no bucket: neither
652    /// [`Class::Default`] nor [`Class::Unshapeable`] reaches [`charge`] at
653    /// all, so neither can have produced the refusal this reports.
654    pub(crate) fn claim_burst_report(&self, class: Class) -> Option<String> {
655        let Class::Rule(index) = class else { return None };
656        let claimed = self.burst_reported.get(index)?.swap(true, Ordering::Relaxed);
657        if claimed {
658            return None;
659        }
660        Some(self.class_name(index))
661    }
662
663    /// Whether the discipline is holding `index` back, and the gate to wait
664    /// on if it is.
665    ///
666    /// Scoped to the classes that **share `bucket`**: a discipline arbitrates
667    /// between classes competing for one bucket, and two classes with their
668    /// own buckets are not competing for anything.
669    fn discipline_holds(
670        &self,
671        state: &mut SchedState,
672        index: usize,
673        bucket: usize,
674    ) -> Option<Gate> {
675        let discipline = self.profile.discipline();
676        // Whoever asked first: the bucket alone decides, and it decides by
677        // arriving at `charge` first. Answered before anything is collected,
678        // so the default discipline allocates nothing per unit.
679        if discipline == Discipline::Fifo {
680            return None;
681        }
682
683        let classes = self.profile.classes();
684        // The classes this one is actually competing with: same bucket, not
685        // itself, and holding something to send.
686        let rivals: Vec<usize> = (0..classes.len())
687            .filter(|&j| j != index && self.class_bucket[j] == bucket && state.demand[j] > 0)
688            .collect();
689
690        match discipline {
691            Discipline::Fifo => return None,
692            Discipline::StrictPriority => {
693                let mine = classes[index].priority;
694                if !rivals.iter().any(|&j| classes[j].priority > mine) {
695                    return None;
696                }
697            }
698            Discipline::WeightedRoundRobin => {
699                if state.credits[index] > 0 {
700                    return None;
701                }
702                if rivals.iter().any(|&j| state.credits[j] > 0) {
703                    // Somebody else still owes this round: wait for them.
704                    // Deliberately *not* a refill — refilling here would let
705                    // a class that spent its share take a second one while a
706                    // rival still had credit, and the ratio would collapse to
707                    // whoever polls most often.
708                } else {
709                    // Every class with demand has spent its share, so the
710                    // round is over. Refill and wake the ones parked on it.
711                    for (j, rule) in classes.iter().enumerate() {
712                        if self.class_bucket[j] == bucket {
713                            state.credits[j] = u32::from(rule.weight);
714                        }
715                    }
716                    self.wake_bucket(state, bucket);
717                    return None;
718                }
719            }
720        }
721
722        // Parked. One gate per park, created under the lock that just read
723        // the demand it waits on, so a withdrawal cannot slip between the
724        // decision and the wait.
725        Some(state.parked[index].get_or_insert_with(Gate::new).clone())
726    }
727
728    /// Release every class parked on `bucket`.
729    ///
730    /// Over-waking is safe and deliberate: a woken class re-asks and parks
731    /// again on a *fresh* gate if it is still held back, which costs one
732    /// loop iteration. Under-waking is a stall until `max_hold`, so the
733    /// asymmetry decides the direction to err in.
734    fn wake_bucket(&self, state: &mut SchedState, bucket: usize) {
735        for j in 0..state.parked.len() {
736            if self.class_bucket[j] == bucket {
737                if let Some(gate) = state.parked[j].take() {
738                    gate.release();
739                }
740            }
741        }
742    }
743
744    /// Record that one stream queue now holds an unreleased head of `class`.
745    ///
746    /// Demand, not depth: one per *queue*, whatever it holds behind that
747    /// head. A discipline arbitrates between classes that have something to
748    /// send, and a class with ten streams waiting is not ten times more
749    /// entitled than a class with one.
750    pub(crate) fn declare_demand(&self, class: Class) {
751        let Class::Rule(index) = class else { return };
752        let mut state = self.state.lock().expect("shape scheduler");
753        if let Some(slot) = state.demand.get_mut(index) {
754            *slot += 1;
755        }
756    }
757
758    /// Undo one [`Self::declare_demand`], waking whatever it was holding back.
759    /// Called from every path that can retire a head — a grant, a `clear`, the
760    /// teardown drain, and `PendingQueue`'s `Drop`. The wake is unconditional
761    /// rather than *only when the count reached zero*, because the classes it
762    /// wakes re-ask and park again for free, while a missed wake is a
763    /// `max_hold` stall on a stream that has nothing wrong with it.
764    pub(crate) fn withdraw_demand(&self, class: Class) {
765        let Class::Rule(index) = class else { return };
766        let Some(&bucket) = self.class_bucket.get(index) else { return };
767        let mut state = self.state.lock().expect("shape scheduler");
768        if let Some(slot) = state.demand.get_mut(index) {
769            *slot = slot.saturating_sub(1);
770        }
771        self.wake_bucket(&mut state, bucket);
772    }
773}
774
775impl std::fmt::Debug for Scheduler {
776    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
777        f.debug_struct("Scheduler").field("profile", &self.profile).finish_non_exhaustive()
778    }
779}
780
781#[cfg(test)]
782mod tests {
783    use super::*;
784    use crate::shape::{
785        BucketConfig, ClassRule, Discipline, MatchKind, Matcher, QueueConfig, RangeSet,
786    };
787    use crate::types::DataStreamType;
788    use moqtap_codec::version::DraftVersion;
789    use std::cell::RefCell;
790
791    fn meta() -> ObjectMeta {
792        ObjectMeta {
793            draft: DraftVersion::Draft19,
794            stream_kind: DataStreamType::Subgroup,
795            track_alias: Some(7),
796            group_id: 3,
797            subgroup_id: Some(4),
798            object_id: 11,
799            publisher_priority: Some(128),
800            index_in_stream: 0,
801            payload_len: 16,
802            status: None,
803            end_of_range: None,
804        }
805    }
806
807    /// A datagram carrying every key one can carry, so a test can knock a
808    /// single one out and attribute the result.
809    fn datagram_meta() -> AnyDatagramMeta {
810        AnyDatagramMeta {
811            track_alias: 7,
812            group_id: 3,
813            object_id: 11,
814            publisher_priority: Some(128),
815            status: None,
816        }
817    }
818
819    fn class(name: &str, matcher: Matcher) -> ClassRule {
820        ClassRule {
821            name: name.to_string(),
822            bucket: "b".to_string(),
823            matcher,
824            weight: 1,
825            ..ClassRule::default()
826        }
827    }
828
829    fn scheduler(classes: Vec<ClassRule>, queue: QueueConfig) -> Scheduler {
830        let bucket = BucketConfig { name: "b".to_string(), ..BucketConfig::default() };
831        Scheduler::new(
832            ShapeProfile::try_new(vec![bucket], classes, queue, Discipline::Fifo)
833                .expect("the fixture names its own bucket"),
834        )
835    }
836
837    /// Collect every `(class index, field)` a classification reported.
838    fn reports<'a>(
839        s: &'a Scheduler,
840        meta: &'a ObjectMeta,
841    ) -> impl FnMut(u64) -> Vec<(usize, MatcherField)> + 'a {
842        move |unit_index| {
843            let seen = RefCell::new(Vec::new());
844            s.classify(ProxySide::ClientToProxy, meta, unit_index, |i, f| {
845                seen.borrow_mut().push((i, f));
846            });
847            seen.into_inner()
848        }
849    }
850
851    /// The first rule that claims a unit wins, and a unit no rule claims
852    /// falls to the default row rather than to the first rule.
853    ///
854    /// *Ablation:* return `Class::Rule(0)` instead of `Class::Default` —
855    /// the `Default` assertion reddens with `left: Rule(0)`.
856    #[test]
857    fn the_first_matching_rule_wins_and_the_rest_fall_to_default() {
858        let s = scheduler(
859            vec![
860                class(
861                    "high",
862                    Matcher { object_id: Some(RangeSet::new([0..=5])), ..Matcher::default() },
863                ),
864                class(
865                    "mid",
866                    Matcher { object_id: Some(RangeSet::new([0..=20])), ..Matcher::default() },
867                ),
868            ],
869            QueueConfig::default(),
870        );
871
872        let low = ObjectMeta { object_id: 3, ..meta() };
873        assert_eq!(
874            s.classify(ProxySide::ClientToProxy, &low, 0, |_, _| {}),
875            Class::Rule(0),
876            "both rules claim object 3; configured order decides"
877        );
878        assert_eq!(s.classify(ProxySide::ClientToProxy, &meta(), 0, |_, _| {}), Class::Rule(1));
879        let out = ObjectMeta { object_id: 99, ..meta() };
880        assert_eq!(s.classify(ProxySide::ClientToProxy, &out, 0, |_, _| {}), Class::Default);
881    }
882
883    /// A rule keyed on a field the wire did not carry falls to default
884    /// **and** reports, once per session per `(class, field)`.
885    ///
886    /// The three separate assertions are the three cases that must not be
887    /// conflated: absent-and-keyed reports, present-but-wrong does not, and
888    /// a second unit of the same shape does not report again.
889    ///
890    /// *Ablation:* drop the `fetch_or` guard and report unconditionally —
891    /// the "once" assertion reddens with two entries for the same pair.
892    #[test]
893    fn an_absent_key_reports_once_per_class_and_field() {
894        let s = scheduler(
895            vec![class(
896                "video",
897                Matcher { subgroup_id: Some(RangeSet::single(4)), ..Matcher::default() },
898            )],
899            QueueConfig::default(),
900        );
901
902        let absent = ObjectMeta { subgroup_id: None, ..meta() };
903        let mut classify = reports(&s, &absent);
904        assert_eq!(
905            classify(0),
906            vec![(0, MatcherField::SubgroupId)],
907            "the wire carried no subgroup ID, so this rule can never fire: say so"
908        );
909        assert_eq!(classify(1), vec![], "once per session per (class, field), not per unit");
910    }
911
912    /// A key that is *present but out of range* is a rule working, not a
913    /// rule that cannot work — so nothing is reported.
914    ///
915    /// Without this the report would fire on every ordinary non-match and
916    /// mean nothing.
917    #[test]
918    fn a_present_but_unmatched_key_reports_nothing() {
919        let s = scheduler(
920            vec![class(
921                "video",
922                Matcher { subgroup_id: Some(RangeSet::single(4)), ..Matcher::default() },
923            )],
924            QueueConfig::default(),
925        );
926        let other = ObjectMeta { subgroup_id: Some(9), ..meta() };
927        assert_eq!(reports(&s, &other)(0), vec![]);
928    }
929
930    /// A rule aimed at datagrams claims a datagram, walks past a framed
931    /// object, and reports nothing about either.
932    ///
933    /// Three assertions because the middle one is what the first would
934    /// otherwise be satisfied by: a `stream_kind` key that had stopped being
935    /// read would claim both, and a rule that claimed neither would fall to
936    /// default on both. The third is the inversion a live `Datagram` rule
937    /// brings — the silence such a rule used to report is not silence any
938    /// more, because it is live and simply did not claim *this* unit.
939    ///
940    /// *Ablation (measured):* have `MatchKind::is_matchable_on` answer
941    /// `false` for `Datagram` again, which is where this started. The first
942    /// run of it left this test **green** and reddened two others, which is
943    /// what put the fresh scheduler below in: an assertion above it had
944    /// already consumed the report.
945    ///
946    /// ```text
947    /// ---- shape::scheduler::tests::a_datagram_rule_claims_a_datagram stdout ----
948    /// assertion `left == right` failed: a Datagram rule is live now, so a
949    /// framed object walking past it is an ordinary non-match and not a
950    /// diagnostic
951    ///   left: [(0, StreamKind)]
952    ///  right: []
953    /// ```
954    #[test]
955    fn a_datagram_rule_claims_a_datagram() {
956        let s = scheduler(
957            vec![class(
958                "dgram",
959                Matcher { stream_kind: Some(MatchKind::Datagram), ..Matcher::default() },
960            )],
961            QueueConfig::default(),
962        );
963        let dgram = datagram_meta();
964        assert_eq!(
965            s.classify_datagram(
966                ProxySide::ClientToProxy,
967                DraftVersion::Draft19,
968                &dgram,
969                0,
970                |_, _| {}
971            ),
972            Class::Rule(0),
973            "a class aimed at datagrams must claim one"
974        );
975
976        let m = meta();
977        assert_eq!(
978            s.classify(ProxySide::ClientToProxy, &m, 0, |_, _| {}),
979            Class::Default,
980            "...and must not claim a framed object, or `stream_kind` is not a key"
981        );
982
983        // A **fresh** scheduler for the report, and this is load-bearing:
984        // the report-once mask is set by whichever call reaches it first,
985        // including one whose reporter discards what it is handed. Asking
986        // the scheduler above would read a mask the assertion before it had
987        // already consumed, and the assertion would pass for every possible
988        // implementation. Measured — the ablation below left it green.
989        let fresh = scheduler(
990            vec![class(
991                "dgram",
992                Matcher { stream_kind: Some(MatchKind::Datagram), ..Matcher::default() },
993            )],
994            QueueConfig::default(),
995        );
996        assert_eq!(
997            reports(&fresh, &m)(0),
998            vec![],
999            "a Datagram rule is live now, so a framed object walking past it is \
1000             an ordinary non-match and not a diagnostic"
1001        );
1002    }
1003
1004    /// **A rule behind the winner is still checked**, so a rule that can
1005    /// never fire is not silenced by whichever rule happened to win.
1006    ///
1007    /// `classify` returned at the first match, so every rule after it —
1008    /// including its unmatchability check — was skipped. A catch-all placed
1009    /// first is the value `ClassRule::default()` hands you, and it silenced
1010    /// every diagnostic behind it for the session's whole life.
1011    ///
1012    /// The two legs are the **same two rules in the two orders**, which is
1013    /// what makes this attributable to the short-circuit and to nothing
1014    /// else: same profile, same unit, same report-once state, one
1015    /// difference. The classification is asserted on both legs too, because
1016    /// a "fix" that reported the shadowed rule by also *letting it win*
1017    /// would break first-match-wins, and the second assertion is what
1018    /// forbids it.
1019    ///
1020    /// *Ablation, recorded:* restore the `return Class::Rule(index)` in
1021    /// place of `claimed = Some(..); continue;` —
1022    ///
1023    /// ```text
1024    /// assertion `left == right` failed: a rule behind the winner can still be
1025    /// one that can never fire, and the winner claiming this unit is not a
1026    /// statement about it
1027    ///   left: []
1028    ///  right: [(1, TrackAlias)]
1029    /// ```
1030    #[test]
1031    fn a_rule_shadowed_by_a_catch_all_is_still_checked() {
1032        let catch_all = || class("everything", Matcher::default());
1033        // A rule keyed on the Track Alias, against a fetch unit, which
1034        // carries a Request ID where a subgroup unit carries an alias — so
1035        // the key is one the unit could not have had rather than one whose
1036        // value was wrong.
1037        let by_alias = || {
1038            class(
1039                "aliased",
1040                Matcher { track_alias: Some(RangeSet::single(7)), ..Matcher::default() },
1041            )
1042        };
1043        let m = ObjectMeta { stream_kind: DataStreamType::Fetch, track_alias: None, ..meta() };
1044
1045        let shadowed = scheduler(vec![catch_all(), by_alias()], QueueConfig::default());
1046        assert_eq!(
1047            reports(&shadowed, &m)(0),
1048            vec![(1, MatcherField::TrackAlias)],
1049            "a rule behind the winner can still be one that can never fire, and \
1050             the winner claiming this unit is not a statement about it"
1051        );
1052        assert_eq!(
1053            shadowed.classify(ProxySide::ClientToProxy, &m, 0, |_, _| {}),
1054            Class::Rule(0),
1055            "...and reporting it must not promote it: the first match still wins"
1056        );
1057
1058        // The control leg: the same two rules in the other order, which
1059        // reported before the fix and must still report exactly once.
1060        let ordered = scheduler(vec![by_alias(), catch_all()], QueueConfig::default());
1061        assert_eq!(reports(&ordered, &m)(0), vec![(0, MatcherField::TrackAlias)]);
1062        assert_eq!(reports(&ordered, &m)(1), vec![], "still once per session per pair");
1063    }
1064
1065    /// The winner is **not** reported, however unmatchable its keys look.
1066    ///
1067    /// The guard against sweeping every rule unconditionally instead,
1068    /// which would fire `ShapeRuleUnmatchable` on the very class that is
1069    /// doing the work and make the report noise.
1070    ///
1071    /// Nearly every key makes this vacuous — an absent key never matches,
1072    /// so a rule keyed on one cannot be the winner. `stream_kind` is the
1073    /// exception and therefore the fixture: `kind_matches(Fetch, Fetch)` is
1074    /// `true` while `Fetch::is_matchable_on(Draft19)` is `false`, so a
1075    /// draft-19 fetch `ObjectMeta` is a unit that a Fetch rule both claims
1076    /// and is "unmatchable" for. The framer never builds one — drafts 15-19
1077    /// have no fetch object codec, so every fetch stream is bypassed at its
1078    /// header — which is exactly why it has to be built here: it is the only
1079    /// input that can tell the `continue` from a `return`.
1080    ///
1081    /// *Ablation, recorded:* drop the `continue` so the winner falls into
1082    /// the sweep — `left: [(0, StreamKind)] / right: []`.
1083    #[test]
1084    fn the_winning_rule_is_never_reported_as_unmatchable() {
1085        let s = scheduler(
1086            vec![class(
1087                "fetch",
1088                Matcher { stream_kind: Some(MatchKind::Fetch), ..Matcher::default() },
1089            )],
1090            QueueConfig::default(),
1091        );
1092        let claimed = ObjectMeta {
1093            draft: DraftVersion::Draft19,
1094            stream_kind: DataStreamType::Fetch,
1095            ..meta()
1096        };
1097        // The reporting assertion goes **first**, and deliberately: the mask
1098        // is once-per-session, so a classification run with a no-op reporter
1099        // would spend the budget and leave the ablation invisible. Measured
1100        // — with the assertions the other way round the `continue` ablation
1101        // ran green.
1102        assert_eq!(
1103            reports(&s, &claimed)(0),
1104            vec![],
1105            "a rule that claimed the unit is a rule that fired; reporting it as \
1106             unable to fire would be a false positive on a working class"
1107        );
1108        assert_eq!(
1109            s.classify(ProxySide::ClientToProxy, &claimed, 0, |_, _| {}),
1110            Class::Rule(0),
1111            "the fixture is only meaningful if the rule really does win"
1112        );
1113
1114        // The contrast in the same body, so the assertion above is not
1115        // passing because this scheduler never reports: a rule against a
1116        // unit it does *not* claim, keyed on something that unit could not
1117        // have carried, reports at once. The key rather than the kind,
1118        // because naming a kind is no longer something a rule can be dead
1119        // for — see `Matcher::unmatchable_fields`.
1120        let s = scheduler(
1121            vec![class(
1122                "aliased",
1123                Matcher { track_alias: Some(RangeSet::single(7)), ..Matcher::default() },
1124            )],
1125            QueueConfig::default(),
1126        );
1127        let unaliased = ObjectMeta { track_alias: None, ..claimed };
1128        assert_eq!(reports(&s, &unaliased)(0), vec![(0, MatcherField::TrackAlias)]);
1129    }
1130
1131    /// The depth reaches `PendingQueue` under `Block` and nowhere else.
1132    ///
1133    /// *Ablation:* return the depth for every overflow — `DropTail`'s
1134    /// `None` assertion reddens, which is the read branch being switched
1135    /// off under the one policy that needs it on.
1136    #[test]
1137    fn only_block_installs_a_blocking_depth() {
1138        let depths = QueueConfig { depth_objects: 4, depth_bytes: 99, ..QueueConfig::default() };
1139        let with = |overflow| QueueConfig { overflow, ..depths.clone() };
1140
1141        let blocking = scheduler(vec![class("c", Matcher::default())], with(Overflow::Block));
1142        assert_eq!(blocking.blocking_depth(), Some(QueueDepth { bytes: 99, objects: 4 }));
1143
1144        for overflow in [Overflow::DropTail, Overflow::ResetStream { code: 7 }] {
1145            assert_eq!(
1146                scheduler(vec![class("c", Matcher::default())], with(overflow)).blocking_depth(),
1147                None,
1148                "{overflow:?} must leave the read branch enabled"
1149            );
1150        }
1151    }
1152
1153    /// Both depth limits, and the policy each one selects.
1154    /// *Ablation:* make the object test `>` instead of `>=` — the *the fourth
1155    /// slot is taken* row admits a fifth unit and reddens.
1156    #[test]
1157    fn admission_applies_both_depths_and_then_the_policy() {
1158        let depths = QueueConfig { depth_objects: 4, depth_bytes: 100, ..QueueConfig::default() };
1159        let with = |overflow| QueueConfig { overflow, ..depths.clone() };
1160        let s = scheduler(vec![class("c", Matcher::default())], with(Overflow::DropTail));
1161
1162        assert_eq!(s.admit(10, 0, 0), Admission::Admit, "an empty queue takes anything that fits");
1163        assert_eq!(s.admit(10, 90, 3), Admission::Admit, "exactly at the byte depth still fits");
1164        assert_eq!(s.admit(11, 90, 3), Admission::DropTail, "one byte over the byte depth");
1165        assert_eq!(s.admit(1, 0, 4), Admission::DropTail, "all four object slots are taken");
1166        assert_eq!(s.admit(1, 0, 3), Admission::Admit, "the fourth slot is free");
1167
1168        let s = scheduler(
1169            vec![class("c", Matcher::default())],
1170            with(Overflow::ResetStream { code: 0x2A }),
1171        );
1172        assert_eq!(s.admit(1, 0, 4), Admission::ResetStream { code: 0x2A });
1173
1174        // `Block` never decides here: a unit that reached admission under
1175        // `Block` proves the queue had room, because the read branch is
1176        // what enforces the limit.
1177        let s = scheduler(vec![class("c", Matcher::default())], with(Overflow::Block));
1178        assert_eq!(s.admit(1, 0, 4), Admission::Admit);
1179    }
1180
1181    // ── release ─────────────────────────────────────────────────────
1182
1183    /// A shaper over one bucket shared by every class, with the discipline
1184    /// and the per-class `(priority, weight)` given.
1185    fn shared_bucket(
1186        rate: Option<u64>,
1187        burst: u64,
1188        discipline: Discipline,
1189        classes: &[(&str, u8, u16)],
1190        now: Instant,
1191    ) -> Scheduler {
1192        let bucket = BucketConfig {
1193            name: "b".to_string(),
1194            rate_bps: rate,
1195            burst_bytes: burst,
1196            ..BucketConfig::default()
1197        };
1198        let rules = classes
1199            .iter()
1200            .map(|(name, priority, weight)| ClassRule {
1201                name: (*name).to_string(),
1202                bucket: "b".to_string(),
1203                matcher: Matcher::default(),
1204                priority: *priority,
1205                weight: *weight,
1206            })
1207            .collect();
1208        Scheduler::at(
1209            ShapeProfile::try_new(vec![bucket], rules, QueueConfig::default(), discipline)
1210                .expect("the fixture names its own bucket"),
1211            now,
1212        )
1213    }
1214
1215    fn granted(a: &Acquire) -> bool {
1216        matches!(a, Acquire::Now)
1217    }
1218
1219    /// Neither of the two rows that name no bucket can be charged one, and
1220    /// neither is arbitrated: an unclassified unit is not a class competing
1221    /// for anything.
1222    ///
1223    /// *Ablation:* charge `Class::Default` to bucket 0 — the zero-rate arm
1224    /// answers `Never` and the assertion reddens, which is a unit no rule
1225    /// claimed being paced by a bucket no rule pointed it at.
1226    #[test]
1227    fn the_unclassified_rows_are_never_charged_a_bucket() {
1228        let base = Instant::now();
1229        // A bucket that grants nothing at all, so a charge is unmissable.
1230        let s = shared_bucket(Some(0), 0, Discipline::Fifo, &[("only", 0, 1)], base);
1231        for class in [Class::Default, Class::Unshapeable] {
1232            assert!(
1233                granted(&s.acquire(class, 4096, base)),
1234                "{class:?} names no bucket, so there is nothing to charge it against"
1235            );
1236        }
1237        // ...and the class that *does* name the bucket is refused, so the
1238        // assertion above is not passing because the bucket grants freely.
1239        assert!(matches!(s.acquire(Class::Rule(0), 1, base), Acquire::Never));
1240    }
1241
1242    /// The bound `charge` proves, reached through `acquire`: a rate-limited
1243    /// class is granted, then refused with the instant its own refill lands.
1244    #[test]
1245    fn a_rate_limited_class_is_refused_with_its_refill_instant() {
1246        let base = Instant::now();
1247        // 1000 bytes/s, 1000-byte burst: the first 1000-byte unit fits, the
1248        // second needs a full second.
1249        let s = shared_bucket(Some(1_000), 1_000, Discipline::Fifo, &[("v", 0, 1)], base);
1250        assert!(granted(&s.acquire(Class::Rule(0), 1_000, base)));
1251        match s.acquire(Class::Rule(0), 1_000, base) {
1252            Acquire::Later(at) => assert_eq!(at, base + Duration::from_secs(1)),
1253            other => panic!("expected a refill instant, got {other:?}"),
1254        }
1255        assert!(granted(&s.acquire(Class::Rule(0), 1_000, base + Duration::from_secs(1))));
1256    }
1257
1258    /// **A burst too small for one unit is its own answer, claimable once**
1259    /// — not the answer a stopped class gives and not the answer a merely
1260    /// slow class gives.
1261    /// All three postures are driven in one body against the same unit size,
1262    /// because the whole claim is that they are distinguishable and a single
1263    /// posture cannot show that. Before the split, the first and the second
1264    /// were the same value and the run reported the same clamp for both, so *1
1265    /// MB/s delivering at the hold clamp* looked exactly like *this class was
1266    /// configured to stop*.
1267    ///
1268    /// *Ablation, recorded:* fold the two refusals back together in `charge`
1269    /// (`if rate == 0 || need > cap { Grant::Never }`) — the first
1270    /// assertion panics with
1271    /// `a rate that was asked for and a burst that cannot cover one object is a
1272    /// misconfiguration, not a rate limit: Never`.
1273    #[test]
1274    fn a_burst_below_one_unit_is_separable_from_a_stopped_or_a_slow_class() {
1275        let base = Instant::now();
1276        const UNIT: u64 = 1_000;
1277
1278        // The defect: a real rate, a burst that cannot hold one object.
1279        let mis_sized = shared_bucket(Some(1_000_000), 100, Discipline::Fifo, &[("v", 0, 1)], base);
1280        match mis_sized.acquire(Class::Rule(0), UNIT, base) {
1281            Acquire::LargerThanBurst { burst_bytes, unit_bytes } => {
1282                assert_eq!((burst_bytes, unit_bytes), (100, UNIT), "both figures, as configured");
1283            }
1284            other => panic!(
1285                "a rate that was asked for and a burst that cannot cover one object \
1286                 is a misconfiguration, not a rate limit: {other:?}"
1287            ),
1288        }
1289        assert_eq!(
1290            mis_sized.claim_burst_report(Class::Rule(0)).as_deref(),
1291            Some("v"),
1292            "the report names the class whose rate is not being applied"
1293        );
1294        assert_eq!(
1295            mis_sized.claim_burst_report(Class::Rule(0)),
1296            None,
1297            "once per session per class: the refusal repeats on every unit and the \
1298             report must not"
1299        );
1300        assert_eq!(
1301            mis_sized.claim_burst_report(Class::Default),
1302            None,
1303            "the rows that name no bucket never reached a bucket to be refused by"
1304        );
1305
1306        // A class configured to stop. Same unit, same clamp downstream, and
1307        // deliberately a different answer: nothing is wrong with it.
1308        let stopped = shared_bucket(Some(0), 0, Discipline::Fifo, &[("v", 0, 1)], base);
1309        assert!(
1310            matches!(stopped.acquire(Class::Rule(0), UNIT, base), Acquire::Never),
1311            "a zero rate is a class doing what it was asked, whatever the burst is"
1312        );
1313
1314        // A class that is merely slow: the burst covers a unit, so the
1315        // bucket refuses with the instant it will hold one again.
1316        let slow = shared_bucket(Some(1_000), UNIT, Discipline::Fifo, &[("v", 0, 1)], base);
1317        assert!(granted(&slow.acquire(Class::Rule(0), UNIT, base)));
1318        match slow.acquire(Class::Rule(0), UNIT, base) {
1319            Acquire::Later(at) => assert_eq!(at, base + Duration::from_secs(1)),
1320            other => panic!("a burst that holds a unit names a refill instant: {other:?}"),
1321        }
1322    }
1323
1324    /// Strict priority's mechanism, without QUIC: on one shared non-zero
1325    /// bucket, the
1326    /// lower-priority class is refused **while the higher one has demand**
1327    /// and released the moment that demand withdraws.
1328    ///
1329    /// The two halves are one test because neither means anything alone: a
1330    /// park that is never released is a stall, and a release that never
1331    /// parked is `Fifo` wearing a different name.
1332    ///
1333    /// *Ablation:* drop the `wake_bucket` call from `withdraw_demand` — the
1334    /// gate assertion reddens, and in the session that is a low class stalled
1335    /// to `max_hold` after the high class has finished.
1336    #[test]
1337    fn strict_priority_parks_the_low_class_until_the_high_one_withdraws() {
1338        let base = Instant::now();
1339        let s = shared_bucket(
1340            Some(1_000_000),
1341            1_000_000,
1342            Discipline::StrictPriority,
1343            &[("audio", 9, 1), ("video", 1, 1)],
1344            base,
1345        );
1346
1347        // Nobody is holding anything: the bucket alone decides, and it grants.
1348        assert!(granted(&s.acquire(Class::Rule(1), 10, base)));
1349
1350        s.declare_demand(Class::Rule(0));
1351        let gate = match s.acquire(Class::Rule(1), 10, base) {
1352            Acquire::Starved(gate) => gate,
1353            other => panic!("audio has demand and outranks video; got {other:?}"),
1354        };
1355        assert!(!gate.is_released(), "the blocking class is still holding it");
1356        // The high class is not held back by the low one's demand.
1357        s.declare_demand(Class::Rule(1));
1358        assert!(granted(&s.acquire(Class::Rule(0), 10, base)));
1359
1360        s.withdraw_demand(Class::Rule(0));
1361        assert!(
1362            gate.is_released(),
1363            "the gate a starved class parked on opens when its blocker drains"
1364        );
1365        assert!(granted(&s.acquire(Class::Rule(1), 10, base)));
1366    }
1367
1368    /// Weighted round robin's mechanism, without QUIC: two classes with
1369    /// demand on one bucket
1370    /// under `WeightedRoundRobin` are granted in their **weight ratio**,
1371    /// because the ratio is a count and not a rate.
1372    ///
1373    /// The bucket is deliberately unlimited, so nothing but the discipline
1374    /// can shape the counts, and each class asks the way a stream's release
1375    /// branch does — `while let Some(unit) = pop_next_due(..)`, i.e. until it
1376    /// is refused. Asking exactly once per turn instead would cap a class at
1377    /// one grant per turn whatever its weight, and the harness rather than
1378    /// the discipline would be setting the ratio. (Measured: that shape gave
1379    /// `[40, 14]` for weights 3:1.)
1380    ///
1381    /// The ratio is asserted as a **band**, not an equality, and the reason
1382    /// is arithmetic rather than caution: a round hands out `weight` grants
1383    /// per class, so the totals are exact only when sampled on a round
1384    /// boundary and the last partial round moves them by at most `weight`.
1385    /// The band still separates 3:1 from 1:1 by a factor of three.
1386    ///
1387    /// *Ablation:* give both classes weight 1 — the counts come out equal and
1388    /// the `>= 2x` assertion reddens.
1389    #[test]
1390    fn weighted_round_robin_grants_in_the_weight_ratio() {
1391        let base = Instant::now();
1392        let s = shared_bucket(
1393            None,
1394            0,
1395            Discipline::WeightedRoundRobin,
1396            &[("a", 0, 3), ("b", 0, 1)],
1397            base,
1398        );
1399        s.declare_demand(Class::Rule(0));
1400        s.declare_demand(Class::Rule(1));
1401
1402        let mut counts = [0u32; 2];
1403        for _ in 0..20 {
1404            for (index, count) in counts.iter_mut().enumerate() {
1405                while granted(&s.acquire(Class::Rule(index), 1, base)) {
1406                    *count += 1;
1407                }
1408            }
1409        }
1410        assert!(counts[1] > 0, "the low-weight class must still be scheduled, not starved");
1411        assert!(
1412            counts[0] >= 2 * counts[1],
1413            "weights 3:1 must give the heavy class at least twice the count: {counts:?}"
1414        );
1415        assert!(counts[0] <= 4 * counts[1], "weights 3:1 are a share, not a monopoly: {counts:?}");
1416    }
1417
1418    /// A class with no rival holding demand takes the whole bucket under
1419    /// `WeightedRoundRobin`, rather than stalling once its own credits run
1420    /// out. A round is over when everyone *who wants it* has had their share.
1421    ///
1422    /// *Ablation:* refill only when `credits[index] == 0` for every class
1423    /// including those with no demand — this loop stops after three grants
1424    /// and reddens.
1425    #[test]
1426    fn weighted_round_robin_does_not_wait_for_a_class_with_nothing_to_send() {
1427        let base = Instant::now();
1428        let s = shared_bucket(
1429            None,
1430            0,
1431            Discipline::WeightedRoundRobin,
1432            &[("a", 0, 3), ("b", 0, 1)],
1433            base,
1434        );
1435        s.declare_demand(Class::Rule(0));
1436        for i in 0..20 {
1437            assert!(granted(&s.acquire(Class::Rule(0), 1, base)), "grant {i} was refused");
1438        }
1439    }
1440
1441    /// `Fifo` arbitrates nothing: demand from another class changes no
1442    /// answer, and the bucket is the only thing that can refuse.
1443    ///
1444    /// This is the control for the two discipline tests above — without it
1445    /// they could both be passing because `acquire` refuses whenever *any*
1446    /// other class has demand.
1447    #[test]
1448    fn fifo_never_parks_a_class() {
1449        let base = Instant::now();
1450        let s = shared_bucket(None, 0, Discipline::Fifo, &[("a", 9, 1), ("b", 0, 1)], base);
1451        s.declare_demand(Class::Rule(0));
1452        for _ in 0..10 {
1453            assert!(granted(&s.acquire(Class::Rule(1), 1_000, base)));
1454        }
1455    }
1456
1457    /// A withdrawal that outnumbers its declarations does not wrap the count
1458    /// into a permanent block.
1459    ///
1460    /// The queue withdraws from `clear`, from the teardown drain and from
1461    /// `Drop`, and those can overlap; a `u64` going through zero would make
1462    /// a class look like it had four billion streams waiting and starve
1463    /// everything under it for the rest of the session.
1464    #[test]
1465    fn withdrawing_more_than_was_declared_saturates_at_zero() {
1466        let base = Instant::now();
1467        let s =
1468            shared_bucket(None, 0, Discipline::StrictPriority, &[("hi", 9, 1), ("lo", 0, 1)], base);
1469        s.declare_demand(Class::Rule(0));
1470        s.withdraw_demand(Class::Rule(0));
1471        s.withdraw_demand(Class::Rule(0));
1472        s.withdraw_demand(Class::Rule(0));
1473        assert!(granted(&s.acquire(Class::Rule(1), 1, base)));
1474    }
1475}