Skip to main content

moqtap_proxy/shape/
stats.rs

1//! Per-class shaping statistics: the atomic storage and its snapshot.
2//!
3//! The split mirrors [`instrument`](crate::instrument) exactly — a
4//! [`ShapeRecorder`] of atomics that the forwarding tasks write, and a
5//! plain-value [`ShapeStats`] that a reader takes. It is a **sibling** of
6//! `Counters`, not an extension of it: `interest_none.rs` compares
7//! `Counters` whole against `Counters::default()` and `actions_*.rs`
8//! compare it by value, so a field added there would have to be threaded
9//! through every one of those assertions, and `Counters` would lose `Copy`
10//! for a `Vec` that is empty on all but shaped sessions.
11//!
12//! # Why a pre-sized `Vec`, indexed by position
13//!
14//! The class list is fixed for a session's lifetime — live reconfigure is
15//! expected to build a new recorder rather than mutate this one. So the
16//! rows are allocated once at session construction, in
17//! [`ShapeProfile::classes`] order, and every increment on
18//! the data path is one relaxed `fetch_add` at a known index. No lock, no
19//! name lookup, no hot-path allocation — the cost model `instrument.rs`
20//! already established.
21//!
22//! Snapshot order is therefore the configured order, deterministically, and
23//! a class that never saw a unit is present with a zero row rather than
24//! absent. A reader asking "what did `video` do?" gets an answer whether or
25//! not `video` did anything, which is the difference between a starved
26//! class and a mis-typed one.
27//!
28//! # What has a producer today
29//!
30//! Two session totals — `objects_seen` and `bytes_shaped` — are written at the
31//! one place a shaped session's framed unit becomes visible, and they exist
32//! here so that *an unshaped session shaped nothing* is falsifiable rather than
33//! vacuous: something has to move when the path *is* entered, or an all-zero
34//! snapshot cannot tell "not armed" from *armed and did nothing*.
35//! `bytes_shaped` additionally counts what **no rule could see** — a stream
36//! header, an oversized object's passthrough chunk, a bypassed stream's tail —
37//! because the `unshapeable` row is a term of the conservation identity and a
38//! term with nothing on the other side of the equals sign is not a term.
39//! `objects_seen` does not: it is the *classifier's* count, and a header is not
40//! an object.
41//!
42//! Admission adds the three rows a policy can move without a clock:
43//! `objects_dropped` / `bytes_dropped` (`Overflow::DropTail`),
44//! `blocked_episodes` (`Overflow::Block`) and `streams_reset_by_shaping`
45//! (`Overflow::ResetStream`). Every one of them is charged to the class the
46//! classifier resolved, so a per-class figure is an answer about a *rule*
47//! and not about a stream.
48//!
49//! Release adds the rest: `bytes_delivered` / `objects_delivered` on every
50//! granted unit, `tokens_exhausted_episodes` when a class's own bucket is
51//! dry, `starved_behind_other_class` when a *different* class's unit was in
52//! the way, `objects_expired` on the `Expiry::ResetStream` arm, and
53//! `streams_with_mixed_classes` once per stream that carried two classes.
54//!
55//! # Session totals carry a direction
56//!
57//! Every session total is stored twice, once per leg: **uplink** is the
58//! client's traffic on its way to the relay, **downlink** is the relay's on
59//! its way to the client. One recorder serves every forwarding task of a
60//! session, so without the split an author shaping both legs reads a single
61//! figure and cannot tell an uplink stall from a downlink one — a
62//! bidirectional run reports downlink starvation as though the uplink class
63//! had caused it.
64//!
65//! The flat totals stay, and they are **derived**: `bytes_shaped` is
66//! `uplink.bytes_shaped + downlink.bytes_shaped`, summed in
67//! [`ShapeRecorder::snapshot`] rather than accumulated in a third atomic.
68//! The data path therefore costs exactly what it did — one relaxed
69//! `fetch_add` into the arriving leg's row — and the aggregate cannot drift
70//! from its parts, which is not a property any pair of independently
71//! written counters has. What that leaves falsifiable is the *attribution*:
72//! charging every byte to one leg keeps the sum right and both legs wrong,
73//! which is what the tests below and `egress.rs`'s two-leg test aim at.
74//!
75//! Per-class rows are deliberately **not** split. An author who wants a
76//! class figure per leg writes two classes and keys each matcher on
77//! [`Matcher::side`](super::Matcher::side); the totals are the ones no
78//! configuration could separate, which is why they are the ones that carry
79//! the direction themselves.
80//!
81//! # The proxy-wide aggregate, and why it is not a sum over live sessions
82//!
83//! [`ProxyRecorder`] is the second recorder in this module: one per
84//! [`TransparentProxy`](crate::proxy::TransparentProxy), held by its control
85//! plane, and charged by the *same* writers that charge the session
86//! recorder — every `note_*` below forwards, so no call site in `session.rs`
87//! or `egress.rs` knows it exists and no figure can be charged to one
88//! recorder and missed by the other.
89//!
90//! Summing the sessions a control plane lists instead would be wrong three
91//! ways, and only the first is fixable. The registry holds no recorder at
92//! all, so there is nothing to sum. The registration is released by a
93//! `Drop`, so a total taken over it would go *down* when a client
94//! disconnected — a statistic that falls under normal operation cannot be
95//! alerted on. And the list is a snapshot of a proxy that keeps moving, so a
96//! sum walked across it is a consistent read of nothing. A recorder that
97//! outlives every session has none of those problems: a session whose whole
98//! future is dropped mid-flight still contributed at the instant each unit
99//! was charged.
100//!
101//! # Where a proxy-level byte is charged: the measurement point
102//!
103//! [`ProxyStats::per_leg`] is a 2×2 — two legs, two directions — and the
104//! two axes are orthogonal, which is exactly what makes the shape worth
105//! having and exactly what makes it easy to fill in wrongly. A proxy holds
106//! two connections; a byte crosses **both**, read on one leg and written on
107//! the other. So the cell is chosen by where the measurement is taken, not
108//! by a label copied off the arriving side:
109//!
110//! * `per_leg[Client].uplink` — bytes read from the client.
111//! * `per_leg[Upstream].uplink` — bytes written to the relay.
112//! * `per_leg[Upstream].downlink` — bytes read from the relay.
113//! * `per_leg[Client].downlink` — bytes written to the client.
114//!
115//! The alternative — deriving a leg from the side a counting site holds — looks
116//! equivalent and carries no information at all. Every hook site is handed
117//! `ClientToProxy` or `RelayToProxy` and nothing else
118//! ([`ShapeProfile::try_new`] refuses a rule keyed on an egress side), and over
119//! those two values leg and direction are the *same* partition: the client row
120//! would be a verbatim copy of the uplink row, the upstream row of the downlink
121//! row, and two of the four cells would be identically zero. Four numbers
122//! carrying two numbers' worth of information, with a reader who took
123//! `per_leg[Upstream]` for *what this proxy sent upstream* getting the figure
124//! for what it received from the client.
125//!
126//! Charging by measurement point makes the difference between the two
127//! uplink cells the shaper's own retention — bytes it read from the client
128//! and did not write to the relay — which is a number that can be non-zero
129//! and therefore a number that can be asserted.
130//!
131//! Only the flow of units is measured twice. The three event figures on a
132//! [`DirectionStats`] — expiries, streams a policy gave up on, streams that
133//! carried two classes — are decisions taken over traffic that *arrived*,
134//! so they are charged to the arrival cell and the departure cell reports
135//! zero for them. That is stated on [`LegStats`] rather than left for a
136//! reader to infer from a zero.
137//!
138//! [`ProxyStats::sessions`] is the flat rollup, and it sums the two
139//! **arrival** cells — the two places a byte enters this proxy, where each
140//! byte is counted exactly once. Summing all four would count every byte
141//! twice, once read and once written.
142//!
143//! **Every figure on this page has a producer.** Worth stating, because it
144//! was not always true: five fields here snapshotted as a constant zero for
145//! a long time, each documenting its own emptiness, and what settled them
146//! was not writing five producers but noticing that none of the five
147//! belonged here.
148//!
149//! Everything on this page is gated on a configured [`ShapeProfile`] — a
150//! proxy running without one reports `ProxyStats::default()` however many
151//! gigabytes it forwards. Two of the five counted what a **hook** does,
152//! which needs no profile at all, so in this type they could only ever have
153//! been a partial count that read zero for every unprofiled session that
154//! delayed or truncated a thousand objects. They are
155//! [`Counters::units_delayed`](crate::instrument::Counters::units_delayed)
156//! and
157//! [`Counters::objects_truncated`](crate::instrument::Counters::objects_truncated)
158//! now, beside
159//! [`Counters::objects_elided`](crate::instrument::Counters::objects_elided),
160//! which had already settled where a hook's decision is counted.
161//!
162//! The other three were `Duration` totals, and a sum of wall-clock time that
163//! the machine's scheduler moves as much as this code does is reportable and
164//! never assertable. The timing dimension is measured instead by
165//! [`Counters::release_errors`](crate::instrument::Counters::release_errors),
166//! which reports a distribution — p50, p95 and an exact maximum — rather
167//! than a total nobody can calibrate.
168//!
169//! With release and the unshapeable row wired, the conservation identity
170//! `Σ classes(delivered + dropped) + default + unshapeable == bytes_shaped`
171//! holds for a stream that ran to completion. Both sides come from the same
172//! measurement — `raw.len()` for a framed object, `Pending::len()` for a
173//! unit no rule saw — taken at the see-point and charged again, unchanged,
174//! at release, so it is an identity over one number and not an agreement
175//! between two.
176//!
177//! Two things break it, both named rather than papered over. A
178//! `STOP_SENDING`-driven teardown, where `propagate_stop` clears the queue
179//! rather than draining it — those bytes are neither delivered nor dropped,
180//! and `Impairment{QueuedBytesAtTeardown}` is what accounts for them. And a
181//! hook action that changes a unit's size after it was seen: `Replace`,
182//! `ReplacePayload` and `Truncate` are all charged in full on the left and
183//! by what they actually wrote on the right. Both are properties of the
184//! *scenario*, not of the recorder, which is why the fixture that asserts
185//! the identity takes no hook action and runs its streams to completion
186//! before it reads.
187
188use std::sync::atomic::{AtomicU64, Ordering};
189use std::sync::{Arc, OnceLock};
190
191use super::scheduler::Class;
192use super::ShapeProfile;
193use crate::types::{Leg, ProxySide};
194
195// ── which leg ──────────────────────────────────────────────────────────
196
197/// Which leg of the proxy a shaped unit is travelling on.
198///
199/// Two variants rather than [`ProxySide`]'s four. A unit is charged on the
200/// side it *arrived* on, and the two egress sides never reach a shaping
201/// decision at all — [`ShapeProfile::try_new`] rejects a rule keyed on one
202/// — but both halves of a leg answer the same question anyway, so the
203/// conversion below is total and no call site has an impossible arm to
204/// invent a value for.
205#[derive(Debug, Clone, Copy, PartialEq, Eq)]
206pub(crate) enum Direction {
207    /// Client → relay: what the client publishes and asks for.
208    Uplink,
209    /// Relay → client: what the client is subscribed to.
210    Downlink,
211}
212
213impl Direction {
214    /// This leg's row in [`ShapeRecorder`]'s session totals.
215    ///
216    /// An index rather than a `match` at each writer: the totals are an
217    /// array precisely so that charging one is the same single
218    /// `fetch_add` at a known offset the class rows already are.
219    fn index(self) -> usize {
220        match self {
221            Direction::Uplink => 0,
222            Direction::Downlink => 1,
223        }
224    }
225}
226
227impl From<ProxySide> for Direction {
228    fn from(side: ProxySide) -> Self {
229        match side {
230            ProxySide::ClientToProxy | ProxySide::ProxyToRelay => Direction::Uplink,
231            ProxySide::RelayToProxy | ProxySide::ProxyToClient => Direction::Downlink,
232        }
233    }
234}
235
236/// The connection a side's traffic is travelling over, and the way it is
237/// going — the pair [`ProxyStats::per_leg`] is indexed by.
238///
239/// A [`ProxySide`] is exactly this pair: `transport.rs` says so in prose,
240/// that `ClientToProxy` and `ProxyToClient` are the two directions of the
241/// client leg and `ProxyToRelay` and `RelayToProxy` the two of the upstream
242/// leg, and this is that sentence written as a total function. Written out
243/// arm by arm rather than composed from [`Direction::from`] and a second
244/// mapping, because the four arms are the definition and a reader checking
245/// the attribution should not have to compose two functions to see it.
246fn split(side: ProxySide) -> (Leg, Direction) {
247    match side {
248        ProxySide::ClientToProxy => (Leg::Client, Direction::Uplink),
249        ProxySide::ProxyToRelay => (Leg::Upstream, Direction::Uplink),
250        ProxySide::RelayToProxy => (Leg::Upstream, Direction::Downlink),
251        ProxySide::ProxyToClient => (Leg::Client, Direction::Downlink),
252    }
253}
254
255/// The leg a unit read on `leg` leaves this proxy by.
256///
257/// A proxy holds two connections and forwards between them, so the leg a
258/// unit is written on is always the other one. The *direction* is unchanged
259/// — traffic the client published is uplink on both legs — which is why
260/// this takes a leg rather than a side: naming the departure cell needs the
261/// leg flipped and nothing else.
262fn opposite(leg: Leg) -> Leg {
263    match leg {
264        Leg::Client => Leg::Upstream,
265        Leg::Upstream => Leg::Client,
266    }
267}
268
269/// This leg's row in [`ProxyStats::per_leg`].
270///
271/// An index rather than a `match` at each writer, for the reason
272/// [`Direction::index`] gives: charging a cell stays one relaxed
273/// `fetch_add` at a known offset.
274fn leg_index(leg: Leg) -> usize {
275    match leg {
276        Leg::Client => 0,
277        Leg::Upstream => 1,
278    }
279}
280
281// ── the snapshot types ─────────────────────────────────────────────────
282
283/// Shaping statistics for one session.
284///
285/// Zero-valued on any session with no [`ShapeProfile`], which is what makes
286/// *this session shaped nothing* a falsifiable claim rather than a promise.
287/// Reported separately from `Counters` so that crate's whole-struct `==
288/// Counters::default()` assertions keep meaning what they mean.
289///
290/// Read through
291/// [`ProxySession::shape_stats`](crate::session::ProxySession::shape_stats).
292///
293/// The session totals appear twice: flat, summed over the whole session,
294/// and again under [`Self::uplink`] and [`Self::downlink`] for one leg
295/// each. The flat figure is the sum of the two by construction, so the two
296/// forms can never disagree — pick the leg when the question is which side
297/// stalled, and the aggregate when it is whether the profile ran at all.
298///
299/// **Every field here is written.** Two were not until recently — the
300/// `Duration` totals on [`ClassStats`], which were calibration figures and
301/// are gone; that type says why there is no duration among these at all. A
302/// zero row is still reported, never omitted — a class that saw nothing is
303/// present and empty, which is the difference between a starved class and a
304/// mis-typed one.
305#[derive(Debug, Clone, Default, PartialEq, Eq)]
306pub struct ShapeStats {
307    /// One entry per configured class, in [`ShapeProfile::classes`] order.
308    pub classes: Vec<ClassStats>,
309    /// Units that matched no rule.
310    pub default_class: ClassStats,
311    /// Units with no object metadata at all: subgroup and fetch stream
312    /// headers, oversized passthrough objects, bypassed streams — every
313    /// fetch stream on drafts 15-19, which have no fetch object codec.
314    /// A **separate** row from [`Self::default_class`], and the distinction is
315    /// the point: the default row is *the rules saw this unit and none claimed
316    /// it*, this row is *no rule could have seen it*. Merging them would make a
317    /// mis-aimed matcher indistinguishable from a stream the framer cannot
318    /// address.
319    ///
320    /// These bytes are **not paced**: they charge no bucket, so a class
321    /// rate can be exceeded by exactly one oversized object.
322    pub unshapeable: ClassStats,
323    /// Hook-visible units the classifier saw.
324    ///
325    /// Objects only. A stream header is counted in [`Self::bytes_shaped`]
326    /// and in [`Self::unshapeable`], and not here.
327    pub objects_seen: u64,
328    /// Bytes the shaper accounted for — every byte it saw, whether a bucket
329    /// granted it, a policy dropped it, or it was unshapeable.
330    /// Deliberately **not** *bytes that passed through a bucket*: the
331    /// `unshapeable` row never touches a bucket, and the conservation identity
332    /// this total exists for — `Σ classes(delivered + dropped) + default +
333    /// unshapeable == bytes_shaped` — has to hold across that row too, or bytes
334    /// the shaper declined to shape would vanish from the accounting.
335    pub bytes_shaped: u64,
336    /// Objects whose `max_hold` elapsed under [`Expiry::ResetStream`].
337    /// Zero under the default [`Expiry::Deliver`], which has no producer.
338    ///
339    /// [`Expiry::ResetStream`]: super::Expiry::ResetStream
340    /// [`Expiry::Deliver`]: super::Expiry::Deliver
341    pub objects_expired: u64,
342    /// Destination streams abandoned by an overflow or expiry policy.
343    pub streams_reset_by_shaping: u64,
344    /// Streams on which two units resolved to different classes.
345    ///
346    /// Head-gating means such a stream's throughput is decided by whichever
347    /// class is at the head, so without this count configured shaping and
348    /// head-of-line blocking are indistinguishable from outside.
349    pub streams_with_mixed_classes: u64,
350    /// The same totals for the client's traffic on its way to the relay.
351    ///
352    /// Every flat total above is this plus [`Self::downlink`], term by
353    /// term. Read a leg when the question is *which side stalled*; read the
354    /// aggregate when it is *did the profile do anything at all*. A session
355    /// shaping only one leg reports the other as all zeros, which is an
356    /// answer rather than an absence.
357    pub uplink: DirectionStats,
358    /// The same totals for the relay's traffic on its way to the client.
359    pub downlink: DirectionStats,
360}
361
362/// Five totals over the traffic travelling **one way**.
363///
364/// Reported in two different containers, and reading a figure here means
365/// knowing which one it came out of. As [`ShapeStats::uplink`] and
366/// [`ShapeStats::downlink`] it is one half of a session's own totals, and
367/// the split is by direction alone — a session recorder has no leg axis. As
368/// one cell of [`ProxyStats::per_leg`] it is one *crossing*: a connection
369/// and a direction together, so the same unit appears in two cells, once
370/// where it was read and once where it was written. [`LegStats`] names the
371/// four.
372///
373/// The distinction is not decoration for the three event figures below.
374/// Every one of them is charged where the traffic **arrived**, so in a
375/// departure cell all three read zero, and in an arrival cell they describe
376/// destination streams that physically live on the *other* connection —
377/// they name which of the two flows the shaper acted on, not which socket
378/// the abandoned stream was on. That is deliberate, and stated on each
379/// field, because putting them on the departure cell would separate them
380/// from the `bytes_shaped` that explains them.
381///
382/// Nothing here is per class: an author who wants a class figure per
383/// direction writes two classes and keys each matcher on
384/// [`Matcher::side`](super::Matcher::side), and these are the totals no
385/// configuration could have separated.
386#[derive(Debug, Clone, Default, PartialEq, Eq)]
387pub struct DirectionStats {
388    /// Hook-visible units the classifier saw. Objects only, for the reason
389    /// [`ShapeStats::objects_seen`] gives — including in a departure cell,
390    /// where a released stream header is bytes and still not an object.
391    pub objects_seen: u64,
392    /// Bytes the shaper accounted for — every byte it saw, whether a bucket
393    /// granted it, a policy dropped it, or it was unshapeable.
394    ///
395    /// The one figure besides `objects_seen` that is measured at both
396    /// crossings, which is what makes the difference between the two cells
397    /// of one direction in [`ProxyStats::per_leg`] the shaper's own
398    /// retention.
399    pub bytes_shaped: u64,
400    /// Objects whose `max_hold` elapsed under
401    /// [`Expiry::ResetStream`](super::Expiry::ResetStream).
402    ///
403    /// Charged to the cell the traffic arrived on. **Zero in a departure
404    /// cell of [`ProxyStats::per_leg`]** — an expired object was read and
405    /// never written, so the leg it would have left by never carried it.
406    pub objects_expired: u64,
407    /// Destination streams abandoned by an overflow or expiry policy,
408    /// counted against the flow whose traffic they were carrying.
409    ///
410    /// Charged to the cell the traffic arrived on, which is **not** the
411    /// connection the abandoned stream is on: a stream carrying what the
412    /// client published is written towards the relay, and this figure
413    /// appears in the client leg's uplink cell beside the `bytes_shaped`
414    /// that explains it. **Zero in a departure cell.**
415    pub streams_reset_by_shaping: u64,
416    /// Streams on which two units resolved to different classes.
417    ///
418    /// Charged to the cell the traffic arrived on, on the same terms as
419    /// [`Self::streams_reset_by_shaping`], and **zero in a departure
420    /// cell**. Note that the event beside it,
421    /// [`ProxyEvent::Impairment`](crate::event::ProxyEvent::Impairment)
422    /// carrying
423    /// [`ImpairmentKind::ClassChangedMidStream`](crate::event::ImpairmentKind::ClassChangedMidStream),
424    /// answers the **other** leg for the same occurrence: an event names the
425    /// connection whose write is affected, and a cell here names the flow
426    /// the figure belongs to. Correlating the two means expecting them to
427    /// disagree by exactly one leg.
428    pub streams_with_mixed_classes: u64,
429}
430
431/// Per-class shaping statistics, one entry in [`ShapeStats`].
432///
433/// Every figure is a count, and there is deliberately no duration among
434/// them. Two once were — a blocked total and a hold total — and both would
435/// have been sums of wall-clock time, movable by the machine's scheduler as
436/// much as by this code, so a gate could only ever have reported them. One
437/// of the two would have misdescribed itself as well:
438/// [`Overflow::Block`](super::Overflow::Block) stops *this crate* calling
439/// `read()` on the source stream and does **not** stall the peer, because
440/// the transport's own receive window absorbs megabytes before a publisher
441/// notices anything — so a blocked time measured here would have been how
442/// long this queue refused to read, never how long the publisher was held
443/// up, and a test reading it as publisher backpressure would have been
444/// measuring the wrong thing under a name that invited it. Real backpressure
445/// needs a small
446/// [`TransportProfile::stream_receive_window`](crate::transport::TransportProfile::stream_receive_window),
447/// which is a transport setting and not a shaping one.
448///
449/// What survives is the load-independent companion a gate can read:
450/// [`Self::blocked_episodes`] and [`Self::tokens_exhausted_episodes`].
451#[derive(Debug, Clone, Default, PartialEq, Eq)]
452pub struct ClassStats {
453    /// The [`ClassRule::name`](super::ClassRule::name) this row reports, or
454    /// an empty string for the default and unshapeable rows.
455    pub name: String,
456    /// Bytes released to the destination stream.
457    pub bytes_delivered: u64,
458    /// Bytes discarded by a policy.
459    pub bytes_dropped: u64,
460    /// Objects released to the destination stream.
461    pub objects_delivered: u64,
462    /// Objects discarded by a policy.
463    pub objects_dropped: u64,
464    /// **Edge-triggered**: distinct starvation episodes, not dequeue
465    /// attempts. Level counting would report the runner's read batching
466    /// rather than the shaper.
467    pub tokens_exhausted_episodes: u64,
468    /// Edge-triggered, same reason: distinct episodes of the read side
469    /// being stalled by [`Overflow::Block`](super::Overflow::Block).
470    pub blocked_episodes: u64,
471    /// Units that waited behind a *different* class's unit on the same
472    /// stream. Separates configured shaping from head-of-line blocking;
473    /// conflating it with `tokens_exhausted_episodes` would hide which of
474    /// the two a scenario actually produced.
475    pub starved_behind_other_class: u64,
476}
477
478// ── the proxy-wide snapshot ────────────────────────────────────────────
479
480/// Shaping statistics for a whole proxy: every session it has accepted,
481/// including the ones that have already ended.
482///
483/// Read through [`ProxyControl::stats`](crate::control::ProxyControl::stats)
484/// and cleared through
485/// [`ProxyControl::reset_stats`](crate::control::ProxyControl::reset_stats).
486/// The figures are **cumulative and monotone** between resets, which is the
487/// property that separates this from
488/// [`ProxyControl::sessions`](crate::control::ProxyControl::sessions): that
489/// list is what is live now and shrinks when a client disconnects, and a
490/// total summed from it would shrink with it. Nothing here is ever removed,
491/// so a session that ended, errored, or had its whole future dropped
492/// mid-flight has already contributed everything it moved.
493///
494/// # Everything here is gated on a configured [`ShapeProfile`]
495///
496/// The writers are the shaping path's, so a proxy running with no profile
497/// reports `ProxyStats::default()` no matter how many gigabytes it forwards.
498/// An all-zero snapshot means **no profile**, not *no traffic*, and the two
499/// are not distinguishable from this type alone — ask
500/// [`ProxyControl::sessions`](crate::control::ProxyControl::sessions) or an
501/// observer's event stream which of the two it is.
502///
503/// # A session driven directly is not in here
504///
505/// A [`ProxySession`](crate::session::ProxySession) constructed by a caller
506/// rather than accepted by a proxy belongs to no control plane, so it has
507/// nowhere to report and keeps only its own
508/// [`ShapeStats`]. That is the same rule
509/// [`ProxyControl::sessions`](crate::control::ProxyControl::sessions)
510/// follows, and for the same reason: a proxy must not claim traffic it never
511/// accepted.
512#[derive(Debug, Clone, Default, PartialEq, Eq)]
513#[non_exhaustive]
514pub struct ProxyStats {
515    /// One row per connection this proxy holds: index `0` is
516    /// [`Leg::Client`], index `1` is [`Leg::Upstream`]. Reach a row by name
517    /// with [`ProxyStats::leg`] rather than by literal index.
518    ///
519    /// A byte crosses **both** legs — read on one, written on the other —
520    /// so a figure here is charged where it was measured and the two legs
521    /// are not two views of one number. See [`LegStats`] for the cell by
522    /// cell statement.
523    pub per_leg: [LegStats; 2],
524    /// The flat rollup over every session, **derived** at snapshot time
525    /// from [`Self::per_leg`] and the class rows rather than accumulated
526    /// into counters of its own.
527    ///
528    /// Derived for the reason a session's own snapshot sums its two legs:
529    /// two independently written counters can disagree, and the disagreement
530    /// surfaces as an identity that fails for no reason a reader could act
531    /// on. A sum taken at read time cannot drift from its parts, which also
532    /// means the rollup identity is a property of this type's shape and not
533    /// something a test could ever falsify.
534    pub sessions: SessionStats,
535    /// One entry per class, in [`ShapeProfile::classes`] order.
536    ///
537    /// Sized **once**, from the first session this proxy accepts that has a
538    /// class to install — a session with no profile, and a session whose
539    /// profile declares no classes, both leave the rows alone — and never
540    /// resized: a `Class::Rule(index)` is an index into the class
541    /// list its own scheduler was built from, so a row set that changed
542    /// shape under a running session would relabel every figure in it. A
543    /// session whose class list does not match, which is what a live
544    /// [`ProxyControl::set_shape`](crate::control::ProxyControl::set_shape)
545    /// with a different set of classes produces, charges
546    /// [`Self::default_class`] instead of a row that would be named for
547    /// somebody else's rule.
548    pub classes: Vec<ClassStats>,
549    /// Units that matched no rule — and units of a session whose class list
550    /// this proxy's rows were not sized for, for the reason
551    /// [`Self::classes`] gives.
552    pub default_class: ClassStats,
553    /// Units no rule could have seen: stream headers, oversized passthrough
554    /// objects, bypassed streams. A separate row from [`Self::default_class`]
555    /// for the reason [`ShapeStats::unshapeable`] gives.
556    pub unshapeable: ClassStats,
557}
558
559impl ProxyStats {
560    /// One leg's row, by name.
561    ///
562    /// [`Self::per_leg`] is an array so the data path can charge a cell at a
563    /// known offset; a reader should not have to remember which offset that
564    /// is, and an index literal at a call site is exactly the kind of
565    /// mistake that reads plausibly forever.
566    pub fn leg(&self, leg: Leg) -> &LegStats {
567        &self.per_leg[leg_index(leg)]
568    }
569}
570
571/// One connection's shaping statistics, split by which way the traffic was
572/// going — one entry of [`ProxyStats::per_leg`].
573///
574/// Two rows and not one. A leg carries traffic both ways, and a leg that
575/// reported a single row would answer *how much crossed this connection* while
576/// refusing "in which direction" — which is the question an author diagnosing a
577/// one-sided stall is actually asking, and the one no configuration could
578/// separate afterwards.
579///
580/// # Which cell a figure lands in
581///
582/// A proxy reads on one leg and writes on the other, so the same unit is
583/// measured twice, once at each crossing:
584///
585/// * `per_leg[Client].uplink` — read from the client.
586/// * `per_leg[Upstream].uplink` — written to the relay.
587/// * `per_leg[Upstream].downlink` — read from the relay.
588/// * `per_leg[Client].downlink` — written to the client.
589///
590/// The difference between the two cells of one direction is what the shaper
591/// kept back: bytes it read and did not write, whether a policy dropped
592/// them, a hook elided them, or a stream was given up on before they went
593/// out.
594///
595/// # Three fields of a departure cell have no producer
596///
597/// Only the flow of units is measured at both crossings. Of the five
598/// figures a [`DirectionStats`] carries, [`DirectionStats::objects_seen`]
599/// and [`DirectionStats::bytes_shaped`] are charged at both;
600/// [`DirectionStats::objects_expired`],
601/// [`DirectionStats::streams_reset_by_shaping`] and
602/// [`DirectionStats::streams_with_mixed_classes`] are decisions taken over
603/// traffic that **arrived**, so they are charged to the arrival cell only
604/// and a departure cell reports zero for all three. Stated here because a
605/// zero that means "no producer" and a zero that means "it did not happen"
606/// are not the same answer.
607///
608/// `objects_seen` stays the classifier's count on both cells: a stream
609/// header is not an object on the way in, and it is still not one on the
610/// way out.
611#[derive(Debug, Clone, Default, PartialEq, Eq)]
612pub struct LegStats {
613    /// This leg's two directions: index `0` is uplink — the client's traffic
614    /// on its way to the relay — and index `1` is downlink. Reach them by
615    /// name with [`LegStats::uplink`] and [`LegStats::downlink`].
616    pub directions: [DirectionStats; 2],
617}
618
619impl LegStats {
620    /// The client's traffic on its way to the relay, on this leg.
621    pub fn uplink(&self) -> &DirectionStats {
622        &self.directions[Direction::Uplink.index()]
623    }
624
625    /// The relay's traffic on its way to the client, on this leg.
626    pub fn downlink(&self) -> &DirectionStats {
627        &self.directions[Direction::Downlink.index()]
628    }
629}
630
631/// What every session this proxy has run did, added up — the flat form of
632/// [`ProxyStats`].
633///
634/// Every field is **derived** at snapshot time, from [`ProxyStats::per_leg`]
635/// or from the class rows, so it cannot drift from the figures beside it.
636/// The two arrival cells are what the totals below are summed from — the two
637/// points a byte enters this proxy, where each byte is counted exactly once.
638/// Summing all four cells would count every byte twice, once where it was
639/// read and once where it was written.
640///
641/// # Every field here has a producer, and the three that did not are gone
642///
643/// Every figure is derived at snapshot time from the per-leg cells and the
644/// class rows, and those are written only by the shaping path. So a figure
645/// counting something a **hook** does — which needs no
646/// [`ShapeProfile`](super::ShapeProfile), while everything here is gated on
647/// one — could not have been a complete count in this type however it was
648/// wired, and two of the three were exactly that. [`Self::objects_dropped`]
649/// below already said where that kind of figure lives: an object a hook
650/// elided is counted on [`Counters`](crate::instrument::Counters), and a
651/// unit a hook delayed and an object it truncated are counted beside it
652/// there now. The third was a `Duration` total; see [`ClassStats`] for why
653/// no statistic on this page is one.
654#[derive(Debug, Clone, Default, PartialEq, Eq)]
655pub struct SessionStats {
656    /// Hook-visible units the classifier saw, over every session. Objects
657    /// only, for the reason [`ShapeStats::objects_seen`] gives.
658    pub objects_seen: u64,
659    /// Objects discarded by a policy, summed over every class row.
660    ///
661    /// Today that means [`Overflow::DropTail`](super::Overflow::DropTail)
662    /// and nothing else: an object a hook elided is the hook's decision
663    /// rather than the shaper's and is counted by
664    /// [`Counters::objects_elided`](crate::instrument::Counters::objects_elided)
665    /// on the session that ran the hook.
666    pub objects_dropped: u64,
667    /// Objects whose `max_hold` elapsed under
668    /// [`Expiry::ResetStream`](super::Expiry::ResetStream). Zero under the
669    /// default [`Expiry::Deliver`](super::Expiry::Deliver), which has no
670    /// producer — correctly, because that arm delivers the object instead.
671    pub objects_expired: u64,
672    /// Destination streams abandoned by an overflow or expiry policy.
673    ///
674    /// Shaping only. A stream reset by a hook, by a peer, or by a mirrored
675    /// teardown is not counted here — those are not decisions this profile
676    /// took, and folding them in would make a configured
677    /// [`Overflow::ResetStream`](super::Overflow::ResetStream) impossible to
678    /// distinguish from a client that went away.
679    pub streams_reset: u64,
680    /// Bytes the shaper accounted for — every byte it saw, whether a bucket
681    /// granted it, a policy dropped it, or it was unshapeable.
682    pub bytes_shaped: u64,
683}
684
685// ── the storage ────────────────────────────────────────────────────────
686
687/// One class's atomic counters — the storage behind one [`ClassStats`].
688///
689/// `name` is immutable for the recorder's lifetime, so it is a plain
690/// `String` rather than anything shared: the class list cannot change
691/// under a running session.
692pub(crate) struct ClassCounters {
693    name: String,
694    bytes_delivered: AtomicU64,
695    bytes_dropped: AtomicU64,
696    objects_delivered: AtomicU64,
697    objects_dropped: AtomicU64,
698    tokens_exhausted_episodes: AtomicU64,
699    blocked_episodes: AtomicU64,
700    starved_behind_other_class: AtomicU64,
701}
702
703impl ClassCounters {
704    /// An all-zero row labelled `name`.
705    fn named(name: String) -> Self {
706        Self {
707            name,
708            bytes_delivered: AtomicU64::new(0),
709            bytes_dropped: AtomicU64::new(0),
710            objects_delivered: AtomicU64::new(0),
711            objects_dropped: AtomicU64::new(0),
712            tokens_exhausted_episodes: AtomicU64::new(0),
713            blocked_episodes: AtomicU64::new(0),
714            starved_behind_other_class: AtomicU64::new(0),
715        }
716    }
717
718    /// Read this row.
719    fn snapshot(&self) -> ClassStats {
720        ClassStats {
721            name: self.name.clone(),
722            bytes_delivered: self.bytes_delivered.load(Ordering::Relaxed),
723            bytes_dropped: self.bytes_dropped.load(Ordering::Relaxed),
724            objects_delivered: self.objects_delivered.load(Ordering::Relaxed),
725            objects_dropped: self.objects_dropped.load(Ordering::Relaxed),
726            tokens_exhausted_episodes: self.tokens_exhausted_episodes.load(Ordering::Relaxed),
727            blocked_episodes: self.blocked_episodes.load(Ordering::Relaxed),
728            starved_behind_other_class: self.starved_behind_other_class.load(Ordering::Relaxed),
729        }
730    }
731
732    /// Zero every counter, keeping the row's name.
733    ///
734    /// Only [`ProxyRecorder`] resets: a session's figures are the session's
735    /// for its whole life. Seven relaxed stores, and deliberately not one
736    /// atomic swap of the whole row — there is no such primitive, and a
737    /// reset that raced traffic would land between two `fetch_add`s whatever
738    /// it was written with. What that costs is a snapshot straddling a reset
739    /// that reports a row part-cleared, which is why the reset is a caller's
740    /// verb and not something this crate does on its own.
741    fn reset(&self) {
742        self.bytes_delivered.store(0, Ordering::Relaxed);
743        self.bytes_dropped.store(0, Ordering::Relaxed);
744        self.objects_delivered.store(0, Ordering::Relaxed);
745        self.objects_dropped.store(0, Ordering::Relaxed);
746        self.tokens_exhausted_episodes.store(0, Ordering::Relaxed);
747        self.blocked_episodes.store(0, Ordering::Relaxed);
748        self.starved_behind_other_class.store(0, Ordering::Relaxed);
749    }
750}
751
752/// One leg's session totals — the storage behind one [`DirectionStats`].
753struct DirectionCounters {
754    objects_seen: AtomicU64,
755    bytes_shaped: AtomicU64,
756    objects_expired: AtomicU64,
757    streams_reset_by_shaping: AtomicU64,
758    streams_with_mixed_classes: AtomicU64,
759}
760
761impl DirectionCounters {
762    /// An all-zero leg.
763    fn new() -> Self {
764        Self {
765            objects_seen: AtomicU64::new(0),
766            bytes_shaped: AtomicU64::new(0),
767            objects_expired: AtomicU64::new(0),
768            streams_reset_by_shaping: AtomicU64::new(0),
769            streams_with_mixed_classes: AtomicU64::new(0),
770        }
771    }
772
773    /// Read this leg.
774    fn snapshot(&self) -> DirectionStats {
775        DirectionStats {
776            objects_seen: self.objects_seen.load(Ordering::Relaxed),
777            bytes_shaped: self.bytes_shaped.load(Ordering::Relaxed),
778            objects_expired: self.objects_expired.load(Ordering::Relaxed),
779            streams_reset_by_shaping: self.streams_reset_by_shaping.load(Ordering::Relaxed),
780            streams_with_mixed_classes: self.streams_with_mixed_classes.load(Ordering::Relaxed),
781        }
782    }
783
784    /// Zero every counter. [`ClassCounters::reset`] states the terms.
785    fn reset(&self) {
786        self.objects_seen.store(0, Ordering::Relaxed);
787        self.bytes_shaped.store(0, Ordering::Relaxed);
788        self.objects_expired.store(0, Ordering::Relaxed);
789        self.streams_reset_by_shaping.store(0, Ordering::Relaxed);
790        self.streams_with_mixed_classes.store(0, Ordering::Relaxed);
791    }
792}
793
794// ── the proxy-wide storage ─────────────────────────────────────────────
795
796/// Proxy-scoped shaping-counter storage — the storage behind [`ProxyStats`].
797///
798/// One per [`TransparentProxy`](crate::proxy::TransparentProxy), owned by
799/// its control plane and handed to each session's [`ShapeRecorder`] as that
800/// session is attached, so it lives for as long as the proxy does and no
801/// session's ending takes anything out of it.
802///
803/// Every counter here is charged by the same call that charges the session
804/// recorder — [`ShapeRecorder`]'s `note_*` methods forward — which is what
805/// makes the two recorders unable to disagree. The data path pays one extra
806/// relaxed `fetch_add` per figure and one `Option` test, on a path that has
807/// already done a `write_all`.
808pub(crate) struct ProxyRecorder {
809    /// The four cells of [`ProxyStats::per_leg`]: `legs[leg][direction]`,
810    /// indexed by [`leg_index`] then [`Direction::index`].
811    legs: [[DirectionCounters; 2]; 2],
812    /// The class rows, installed by the first session to attach with a class
813    /// to install — see [`Self::adopt_classes`] for the two kinds of session
814    /// that have none and must not win this.
815    ///
816    /// A [`OnceLock`] and not a `Mutex`, because the list must be **fixed**:
817    /// a `Class::Rule(index)` is an index into the class list of the
818    /// scheduler that produced it, so rows that could be resized under a
819    /// running session would silently relabel every figure in them. First
820    /// writer wins; a later session whose class list differs charges
821    /// [`Self::default_class`], which [`Self::row`] does and
822    /// [`ProxyStats::classes`] states.
823    ///
824    /// Empty until the first shaped session, so a proxy that has accepted
825    /// nothing, or only unshaped sessions, reports no class rows rather than
826    /// rows invented from a profile nothing ran under.
827    classes: OnceLock<Vec<ClassCounters>>,
828    default_class: ClassCounters,
829    unshapeable: ClassCounters,
830}
831
832impl ProxyRecorder {
833    /// A recorder with four all-zero cells and no class rows yet.
834    pub(crate) fn new() -> Self {
835        Self {
836            legs: [
837                [DirectionCounters::new(), DirectionCounters::new()],
838                [DirectionCounters::new(), DirectionCounters::new()],
839            ],
840            classes: OnceLock::new(),
841            // Unnamed by contract, exactly as `ShapeRecorder::for_profile`
842            // leaves them: an empty `name` is what distinguishes these two
843            // rows from a class somebody wrote.
844            default_class: ClassCounters::named(String::new()),
845            unshapeable: ClassCounters::named(String::new()),
846        }
847    }
848
849    /// Size the class rows from `names` if nothing has yet, and answer
850    /// whether the installed rows are `names` — which is whether a session
851    /// running that class list may charge them by index.
852    ///
853    /// The comparison is over names **and order**, the same pair
854    /// `same_classes` compares in `session.rs` and for the same reason: that
855    /// pair is what makes a `Class::Rule(index)` mean the same thing to the
856    /// scheduler that produced it and to the row it is charged to. The same
857    /// names in a different order would charge every class to another one's
858    /// row without a single count going missing.
859    ///
860    /// # An empty list never sizes anything
861    ///
862    /// A list with no rows in it names no row, so letting it win the
863    /// `OnceLock` would install an empty row set that nothing can ever match
864    /// again: every classed session accepted for the rest of the proxy's life
865    /// would find rows it did not match and charge [`Self::default_class`],
866    /// with [`ProxyStats::classes`] empty forever. Every figure right, every
867    /// label gone — which is precisely the relabelling this whole sizing rule
868    /// exists to prevent, and it would arrive silently and be unrecoverable
869    /// without restarting the proxy.
870    ///
871    /// The way an empty list used to get here was a [`ShapeProfile`] with no
872    /// classes, which the constructor accepted because it validated each
873    /// class it was *given* and had nothing to say about being given none.
874    /// [`ShapeProfile::try_new`] now refuses that outright as
875    /// [`ShapeError::NoClasses`](super::ShapeError::NoClasses), so no
876    /// profile can carry an empty list to this call and the branch below is
877    /// no longer reachable from any public path.
878    ///
879    /// It stays because of what it costs against what it prevents: two lines
880    /// and a comparison that is already being made, against a proxy-wide,
881    /// silent, restart-only failure. Answering `false` costs a caller nothing
882    /// in any case — a session with no `Class::Rule` to charge is unaffected
883    /// by the flag, and `Class::Default` and `Class::Unshapeable` are
884    /// unaffected by it always.
885    fn adopt_classes(&self, names: &[String]) -> bool {
886        if names.is_empty() {
887            return false;
888        }
889        let rows =
890            self.classes.get_or_init(|| names.iter().cloned().map(ClassCounters::named).collect());
891        rows.len() == names.len() && rows.iter().zip(names).all(|(row, name)| &row.name == name)
892    }
893
894    /// The class rows, or an empty slice before any shaped session attached.
895    fn classes(&self) -> &[ClassCounters] {
896        self.classes.get().map_or(&[], Vec::as_slice)
897    }
898
899    /// The cell a unit **read** on `side` is charged to.
900    fn arrival(&self, side: ProxySide) -> &DirectionCounters {
901        let (leg, direction) = split(side);
902        &self.legs[leg_index(leg)][direction.index()]
903    }
904
905    /// The cell a unit read on `side` is charged to when it is **written**:
906    /// the other leg, the same direction.
907    fn departure(&self, side: ProxySide) -> &DirectionCounters {
908        let (leg, direction) = split(side);
909        &self.legs[leg_index(opposite(leg))][direction.index()]
910    }
911
912    /// The row one class's units are charged to.
913    ///
914    /// `sized` is whether the charging session's class list is the one these
915    /// rows were sized from. When it is not, a `Class::Rule` index names a
916    /// row belonging to a different rule, so the unit goes to the default
917    /// row instead — the same answer [`ShapeRecorder::row`] gives an
918    /// impossible index, and for the stronger of the two reasons: here the
919    /// index is not impossible, it is *plausible and wrong*.
920    ///
921    /// [`Class::Unshapeable`] is unaffected either way. That row is not
922    /// named for a rule, so no reconfiguration can make it mean something
923    /// else.
924    fn row(&self, class: Class, sized: bool) -> &ClassCounters {
925        match class {
926            Class::Unshapeable => &self.unshapeable,
927            Class::Rule(index) if sized => self.classes().get(index).unwrap_or(&self.default_class),
928            _ => &self.default_class,
929        }
930    }
931
932    /// Read every counter.
933    ///
934    /// Allocates: one `Vec` and one `String` per class row. A reader's call —
935    /// the control plane's — never the data path's.
936    ///
937    /// [`ProxyStats::sessions`] is **computed here**, from the two arrival
938    /// cells and the class rows, which is why no writer maintains it.
939    pub(crate) fn snapshot(&self) -> ProxyStats {
940        let per_leg = [self.leg_stats(Leg::Client), self.leg_stats(Leg::Upstream)];
941        let classes: Vec<ClassStats> = self.classes().iter().map(ClassCounters::snapshot).collect();
942        let default_class = self.default_class.snapshot();
943        let unshapeable = self.unshapeable.snapshot();
944
945        // The two cells traffic *enters* by. Every other cell reports what
946        // left, and a byte that entered and left would be counted twice.
947        let from_client = per_leg[leg_index(Leg::Client)].uplink();
948        let from_relay = per_leg[leg_index(Leg::Upstream)].downlink();
949        let objects_dropped = classes
950            .iter()
951            .chain([&default_class, &unshapeable])
952            .fold(0u64, |sum, row| sum.saturating_add(row.objects_dropped));
953
954        let sessions = SessionStats {
955            objects_seen: from_client.objects_seen.saturating_add(from_relay.objects_seen),
956            objects_dropped,
957            objects_expired: from_client.objects_expired.saturating_add(from_relay.objects_expired),
958            streams_reset: from_client
959                .streams_reset_by_shaping
960                .saturating_add(from_relay.streams_reset_by_shaping),
961            bytes_shaped: from_client.bytes_shaped.saturating_add(from_relay.bytes_shaped),
962        };
963
964        ProxyStats { per_leg, sessions, classes, default_class, unshapeable }
965    }
966
967    /// One leg's two cells.
968    fn leg_stats(&self, leg: Leg) -> LegStats {
969        let rows = &self.legs[leg_index(leg)];
970        LegStats { directions: [rows[0].snapshot(), rows[1].snapshot()] }
971    }
972
973    /// Zero every counter this proxy holds, keeping the class rows and their
974    /// names.
975    ///
976    /// The rows survive because they are what a `Class::Rule(index)` means:
977    /// dropping them would let the next session install a different class
978    /// list and charge figures under it, which is precisely the relabelling
979    /// [`Self::adopt_classes`] exists to prevent. A reset moves the counters
980    /// to zero, not the schema.
981    pub(crate) fn reset(&self) {
982        for leg in &self.legs {
983            for cell in leg {
984                cell.reset();
985            }
986        }
987        for row in self.classes() {
988            row.reset();
989        }
990        self.default_class.reset();
991        self.unshapeable.reset();
992    }
993}
994
995impl std::fmt::Debug for ProxyRecorder {
996    /// Prints the snapshot, not the atomics.
997    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
998        f.debug_tuple("ProxyRecorder").field(&self.snapshot()).finish()
999    }
1000}
1001
1002/// Session-scoped shaping-counter storage.
1003///
1004/// One per `ProxySession`, held behind an `Arc` and cloned into every
1005/// forwarding task exactly as `Recorder` is, so two sessions running
1006/// side by side in one test binary cannot see each other's increments.
1007///
1008/// **Always constructed**, including for a session with no profile, on the
1009/// same reasoning `StreamRegistry` is. An unshaped session's
1010/// recorder has an empty class list and no writer, so it snapshots as
1011/// `ShapeStats::default()`; making construction conditional would replace
1012/// one always-zero allocation with an `Option` on the hot context and prove
1013/// nothing extra.
1014pub(crate) struct ShapeRecorder {
1015    classes: Vec<ClassCounters>,
1016    default_class: ClassCounters,
1017    unshapeable: ClassCounters,
1018    /// The session totals, one row per leg, indexed by [`Direction`].
1019    ///
1020    /// One recorder serves every forwarding task of a session, so a single
1021    /// row would answer "how much was shaped" and refuse to answer "on
1022    /// which side" — the question an author shaping both legs is actually
1023    /// asking. Two rows and an index cost the writers nothing: charging a
1024    /// total is still one relaxed `fetch_add` at a known offset.
1025    totals: [DirectionCounters; 2],
1026    /// This session's proxy's counters, or `None` when it has no proxy.
1027    ///
1028    /// Forwarded to by every writer below rather than reached by a separate
1029    /// call at each site. One call charges both recorders or neither, so
1030    /// there is no way to add a producer to a session figure and forget the
1031    /// proxy figure beside it — which is the failure a second set of call
1032    /// sites would make inevitable and silent.
1033    ///
1034    /// `None` is not a degraded mode. A session constructed directly belongs
1035    /// to no proxy, so there is no aggregate for it to be part of, and it
1036    /// keeps reporting its own [`ShapeStats`] exactly as it always did.
1037    proxy: Option<Arc<ProxyRecorder>>,
1038    /// Whether this session's class list is the one the proxy's rows were
1039    /// sized from, which decides whether a `Class::Rule(index)` may address
1040    /// them. Resolved once at attach; see [`ProxyRecorder::row`].
1041    proxy_sized: bool,
1042}
1043
1044impl ShapeRecorder {
1045    /// Pre-size the rows for `profile`'s classes, in configured order.
1046    ///
1047    /// `None` gives a recorder with no class rows — the unshaped session's
1048    /// shape, whose snapshot is `ShapeStats::default()`.
1049    ///
1050    /// The recorder this builds reports to no proxy. That is the right
1051    /// answer for a session driven directly, and
1052    /// [`Self::attached`] is what the accept loop
1053    /// uses instead.
1054    pub(crate) fn for_profile(profile: Option<&ShapeProfile>) -> Self {
1055        let classes = profile
1056            .map(|p| p.classes().iter().map(|c| ClassCounters::named(c.name.clone())).collect())
1057            .unwrap_or_default();
1058        Self {
1059            classes,
1060            // The default and unshapeable rows are unnamed by contract:
1061            // an empty `name` is what distinguishes them from a class the
1062            // user wrote, and a user-written class name is unique by
1063            // `ShapeError::DuplicateClassName`, so there is no collision.
1064            default_class: ClassCounters::named(String::new()),
1065            unshapeable: ClassCounters::named(String::new()),
1066            totals: [DirectionCounters::new(), DirectionCounters::new()],
1067            proxy: None,
1068            proxy_sized: false,
1069        }
1070    }
1071
1072    /// The same recorder, additionally reporting into `proxy`.
1073    ///
1074    /// Built at the one moment a session is attached to a control plane —
1075    /// after it is constructed and before it runs — so the recorder that
1076    /// forwards is the recorder every forwarding task will clone, and no
1077    /// figure is charged before the forwarding target is in place.
1078    ///
1079    /// An unshaped session does **not** size the proxy's class rows. It has
1080    /// no classes to install, and installing its empty list would mean the
1081    /// first shaped session to arrive afterwards found rows it did not match
1082    /// and charged the default row forever. Nothing is lost by skipping it:
1083    /// an unshaped session's writers are all behind a configured profile, so
1084    /// it charges nothing at all.
1085    ///
1086    /// A **shaped** session cannot be in the same position any more:
1087    /// [`ShapeProfile::try_new`] refuses a profile with no classes, so
1088    /// `Some(profile)` always carries at least one class name to install.
1089    /// [`ProxyRecorder::adopt_classes`] keeps its own guard against an empty
1090    /// list all the same, and says there why.
1091    pub(crate) fn attached(profile: Option<&ShapeProfile>, proxy: Arc<ProxyRecorder>) -> Self {
1092        let mut recorder = Self::for_profile(profile);
1093        recorder.proxy_sized = match profile {
1094            Some(profile) => {
1095                let names: Vec<String> = profile.classes().iter().map(|c| c.name.clone()).collect();
1096                proxy.adopt_classes(&names)
1097            }
1098            None => false,
1099        };
1100        recorder.proxy = Some(proxy);
1101        recorder
1102    }
1103
1104    /// This session's proxy row for `class`, when it has a proxy.
1105    fn proxy_row(&self, class: Class) -> Option<&ClassCounters> {
1106        self.proxy.as_ref().map(|proxy| proxy.row(class, self.proxy_sized))
1107    }
1108
1109    /// Snapshot every counter for this session.
1110    ///
1111    /// Allocates: one `Vec` and one `String` per class row. Called by a
1112    /// reader (a test, or a control plane), never on the data path.
1113    ///
1114    /// The flat session totals are **computed here** from the two legs,
1115    /// which is why no writer maintains them. Two independently written
1116    /// counters can disagree, and the disagreement would surface as a
1117    /// conservation identity that fails for no reason a reader could act
1118    /// on; a sum taken at read time cannot.
1119    pub(crate) fn snapshot(&self) -> ShapeStats {
1120        let uplink = self.leg(Direction::Uplink).snapshot();
1121        let downlink = self.leg(Direction::Downlink).snapshot();
1122        ShapeStats {
1123            classes: self.classes.iter().map(ClassCounters::snapshot).collect(),
1124            default_class: self.default_class.snapshot(),
1125            unshapeable: self.unshapeable.snapshot(),
1126            objects_seen: uplink.objects_seen.saturating_add(downlink.objects_seen),
1127            bytes_shaped: uplink.bytes_shaped.saturating_add(downlink.bytes_shaped),
1128            objects_expired: uplink.objects_expired.saturating_add(downlink.objects_expired),
1129            streams_reset_by_shaping: uplink
1130                .streams_reset_by_shaping
1131                .saturating_add(downlink.streams_reset_by_shaping),
1132            streams_with_mixed_classes: uplink
1133                .streams_with_mixed_classes
1134                .saturating_add(downlink.streams_with_mixed_classes),
1135            uplink,
1136            downlink,
1137        }
1138    }
1139
1140    /// One framed unit entered the shaping path, carrying `bytes` on the
1141    /// wire.
1142    ///
1143    /// Two relaxed `fetch_add`s, called from the object arm of
1144    /// `pipe_data_framed` **behind `ForwardCtx::shaping_enabled`** — so a
1145    /// session with no profile never reaches it, and `interest_none.rs`
1146    /// stays at an all-zero `Counters` and an all-zero `ShapeStats`
1147    /// together.
1148    ///
1149    /// This is the *seen* count, not a delivery count: it is taken where
1150    /// the unit becomes visible to the shaper, before any classification or
1151    /// bucket exists to say what became of it. `bytes_shaped` is therefore
1152    /// the left-hand side of the conservation identity from the start, and
1153    /// the units that add the per-class rows are adding the right-hand
1154    /// side rather than re-defining this one.
1155    /// `side` is the side the unit **arrived** on, which the caller already
1156    /// holds as the side it reports every other event for. Charging it here
1157    /// rather than deriving it later is what keeps *the downlink stalled*
1158    /// separable from "the uplink did", and it is a side rather than a
1159    /// [`Direction`] because the proxy-wide rows need the leg as well — a
1160    /// caller that passed a direction would have thrown away exactly the half
1161    /// of the answer [`ProxyStats::per_leg`] exists for.
1162    pub(crate) fn note_object_seen(&self, side: ProxySide, bytes: u64) {
1163        let leg = self.leg(Direction::from(side));
1164        leg.objects_seen.fetch_add(1, Ordering::Relaxed);
1165        leg.bytes_shaped.fetch_add(bytes, Ordering::Relaxed);
1166        if let Some(proxy) = &self.proxy {
1167            let cell = proxy.arrival(side);
1168            cell.objects_seen.fetch_add(1, Ordering::Relaxed);
1169            cell.bytes_shaped.fetch_add(bytes, Ordering::Relaxed);
1170        }
1171    }
1172
1173    /// `bytes` no rule could see entered the shaping path.
1174    /// The sibling of [`Self::note_object_seen`] for a unit with no
1175    /// `ObjectMeta` — a stream header, an oversized object's passthrough chunk,
1176    /// a bypassed stream's tail. One relaxed `fetch_add`, and deliberately
1177    /// **not** two: `objects_seen` counts what the *classifier* saw, and a
1178    /// header is not an object. Bumping it here would make the count that every
1179    /// fixture anchors on (*wait until all twelve objects have been
1180    /// classified*) depend on how many stream headers happened to arrive first.
1181    ///
1182    /// `bytes_shaped` does move, because it is the left-hand side of the
1183    /// conservation identity and the `unshapeable` row is one of that
1184    /// identity's right-hand terms. The row itself is charged on release,
1185    /// from the same `unit.len()`.
1186    pub(crate) fn note_unshapeable_seen(&self, side: ProxySide, bytes: u64) {
1187        self.leg(Direction::from(side)).bytes_shaped.fetch_add(bytes, Ordering::Relaxed);
1188        if let Some(proxy) = &self.proxy {
1189            proxy.arrival(side).bytes_shaped.fetch_add(bytes, Ordering::Relaxed);
1190        }
1191    }
1192
1193    /// One unit of `bytes` was discarded by [`Overflow::DropTail`], charged
1194    /// to the class that claimed it.
1195    ///
1196    /// No side, because nothing this moves is per leg: a drop is a fact
1197    /// about a *rule*, and the leg it happened on is already in the
1198    /// difference between the two cells of that direction — the bytes were
1199    /// charged where they arrived and are never charged where they would
1200    /// have left.
1201    ///
1202    /// [`Overflow::DropTail`]: super::Overflow::DropTail
1203    pub(crate) fn note_dropped(&self, class: Class, bytes: u64) {
1204        for row in [Some(self.row(class)), self.proxy_row(class)].into_iter().flatten() {
1205            row.objects_dropped.fetch_add(1, Ordering::Relaxed);
1206            row.bytes_dropped.fetch_add(bytes, Ordering::Relaxed);
1207        }
1208    }
1209
1210    /// One **episode** of the read side being stalled by
1211    /// [`Overflow::Block`] began.
1212    ///
1213    /// Edge-triggered by the caller, which owns the per-stream latch: level
1214    /// counting here would report the runner's read batching rather than
1215    /// the shaper. Charged to the class of the last unit classified on the
1216    /// stream — the one whose admission filled the queue — because a stall
1217    /// is a property of a stream and a stream has no single class.
1218    ///
1219    /// [`Overflow::Block`]: super::Overflow::Block
1220    pub(crate) fn note_blocked(&self, class: Class) {
1221        for row in [Some(self.row(class)), self.proxy_row(class)].into_iter().flatten() {
1222            row.blocked_episodes.fetch_add(1, Ordering::Relaxed);
1223        }
1224    }
1225
1226    /// A destination stream carrying traffic that arrived on `side` was
1227    /// abandoned by a shaping policy.
1228    ///
1229    /// Charged to the **arrival** cell, like every other decision figure,
1230    /// and not to the leg the abandoned stream is physically on. What a
1231    /// reader wants from it is which of the two flows the profile gave up
1232    /// on, and the flow is named by where its traffic came from; splitting
1233    /// this one figure the other way would put it in a different cell from
1234    /// the `bytes_shaped` that explains it.
1235    pub(crate) fn note_stream_reset_by_shaping(&self, side: ProxySide) {
1236        self.leg(Direction::from(side)).streams_reset_by_shaping.fetch_add(1, Ordering::Relaxed);
1237        if let Some(proxy) = &self.proxy {
1238            proxy.arrival(side).streams_reset_by_shaping.fetch_add(1, Ordering::Relaxed);
1239        }
1240    }
1241
1242    /// One unit of `bytes` was released to the destination stream.
1243    ///
1244    /// The right-hand term the conservation identity was missing: with this
1245    /// producer in place, `Σ classes(delivered + dropped) + default +
1246    /// unshapeable == bytes_shaped` holds for a stream that ran to
1247    /// completion. It is charged from the same `raw.len()` `note_object_seen`
1248    /// took, so the identity is over one measurement rather than two.
1249    /// A teardown flush counts here too. `write_all` returning is the only
1250    /// definition of *released to the destination stream* this side of the
1251    /// transport, and the bytes it could not vouch for are separately reported
1252    /// as `QueuedBytesAtTeardown` — an over-report there is a diagnosable
1253    /// nuisance, a byte missing from both is a hole in the identity.
1254    ///
1255    /// `side` is still the side the unit **arrived** on — every caller holds
1256    /// that one and only that one — and it is the proxy-wide rows that turn
1257    /// it around: a release is the second and last time a unit is measured,
1258    /// on the leg it is leaving by, so [`ProxyRecorder::departure`] is what
1259    /// this charges. The session's own rows are untouched by that, because
1260    /// they have no leg axis to be wrong about.
1261    ///
1262    /// The proxy's departure cell counts an object only for a unit that
1263    /// carried one. [`Class::Unshapeable`] is the tag every unit no rule
1264    /// could see is pushed with — a stream header, an oversized passthrough
1265    /// chunk — and `objects_seen` is the *classifier's* count on both cells
1266    /// or it is not one figure at all: a header is not an object on the way
1267    /// in and it is not one on the way out.
1268    pub(crate) fn note_delivered(&self, side: ProxySide, class: Class, bytes: u64) {
1269        for row in [Some(self.row(class)), self.proxy_row(class)].into_iter().flatten() {
1270            row.objects_delivered.fetch_add(1, Ordering::Relaxed);
1271            row.bytes_delivered.fetch_add(bytes, Ordering::Relaxed);
1272        }
1273        if let Some(proxy) = &self.proxy {
1274            let cell = proxy.departure(side);
1275            cell.bytes_shaped.fetch_add(bytes, Ordering::Relaxed);
1276            if class != Class::Unshapeable {
1277                cell.objects_seen.fetch_add(1, Ordering::Relaxed);
1278            }
1279        }
1280    }
1281
1282    /// One **episode** of a class's own bucket being dry began.
1283    /// Edge-triggered by the caller, which owns the per-stream latch, for the
1284    /// same reason [`Self::note_blocked`] is: a level count would report how
1285    /// often the release branch woke rather than how often the shaper ran out.
1286    /// Deliberately **not** bumped when a [`Discipline`](super::Discipline) is
1287    /// what held the unit back — that is [`Self::note_starved`], and conflating
1288    /// the two would make *my bucket is too small* and *another class is ahead
1289    /// of me* one unactionable number.
1290    pub(crate) fn note_tokens_exhausted(&self, class: Class) {
1291        for row in [Some(self.row(class)), self.proxy_row(class)].into_iter().flatten() {
1292            row.tokens_exhausted_episodes.fetch_add(1, Ordering::Relaxed);
1293        }
1294    }
1295
1296    /// One unit waited behind a unit of a *different* class.
1297    ///
1298    /// Per unit, once — the queue marks a unit as it charges it. Both causes
1299    /// of head-of-line waiting land here: a discipline holding this class
1300    /// back behind a rival, and a same-stream head of another class that the
1301    /// single per-stream FIFO cannot be reordered around.
1302    pub(crate) fn note_starved(&self, class: Class) {
1303        for row in [Some(self.row(class)), self.proxy_row(class)].into_iter().flatten() {
1304            row.starved_behind_other_class.fetch_add(1, Ordering::Relaxed);
1305        }
1306    }
1307
1308    /// One queued object outlived its clamp under
1309    /// [`Expiry::ResetStream`](super::Expiry::ResetStream).
1310    ///
1311    /// Zero under the default [`Expiry::Deliver`](super::Expiry::Deliver),
1312    /// which delivers the object instead of expiring it — that arm has no
1313    /// producer here and deliberately so.
1314    ///
1315    /// Charged to `side`'s **arrival** cell: the object was read and never
1316    /// written, so the leg it would have left by never carried it.
1317    pub(crate) fn note_expired(&self, side: ProxySide) {
1318        self.leg(Direction::from(side)).objects_expired.fetch_add(1, Ordering::Relaxed);
1319        if let Some(proxy) = &self.proxy {
1320            proxy.arrival(side).objects_expired.fetch_add(1, Ordering::Relaxed);
1321        }
1322    }
1323
1324    /// One stream carried units of two different classes.
1325    ///
1326    /// Once per stream, latched by the caller. Head-gating means such a
1327    /// stream's throughput is decided by whichever class is at its head, so
1328    /// without this count a scenario author cannot tell configured shaping
1329    /// from head-of-line blocking.
1330    pub(crate) fn note_mixed_class_stream(&self, side: ProxySide) {
1331        self.leg(Direction::from(side)).streams_with_mixed_classes.fetch_add(1, Ordering::Relaxed);
1332        if let Some(proxy) = &self.proxy {
1333            proxy.arrival(side).streams_with_mixed_classes.fetch_add(1, Ordering::Relaxed);
1334        }
1335    }
1336
1337    /// The row one class's units are charged to.
1338    ///
1339    /// A [`Class::Rule`] index out of range cannot happen — the indices
1340    /// come from the same profile the rows were pre-sized from — but this
1341    /// is on a forwarding task, where a total function beats a panicking
1342    /// one: an impossible index charges the default row rather than killing
1343    /// the stream.
1344    fn row(&self, class: Class) -> &ClassCounters {
1345        match class {
1346            Class::Rule(index) => self.classes.get(index).unwrap_or(&self.default_class),
1347            Class::Default => &self.default_class,
1348            Class::Unshapeable => &self.unshapeable,
1349        }
1350    }
1351
1352    /// The session totals one leg's units are charged to.
1353    fn leg(&self, direction: Direction) -> &DirectionCounters {
1354        &self.totals[direction.index()]
1355    }
1356}
1357
1358impl std::fmt::Debug for ShapeRecorder {
1359    /// Prints the snapshot, not the atomics.
1360    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1361        f.debug_tuple("ShapeRecorder").field(&self.snapshot()).finish()
1362    }
1363}
1364
1365#[cfg(test)]
1366mod tests {
1367    use super::*;
1368    use crate::shape::{BucketConfig, ClassRule, Discipline, Matcher, QueueConfig};
1369
1370    /// A valid profile whose classes are `names`, in that order. Struct
1371    /// literals rather than field assignment: `#[non_exhaustive]` does not
1372    /// apply inside the defining crate, and `..Default::default()` is what
1373    /// clippy's `field_reassign_with_default` asks for.
1374    fn profile(names: &[&str]) -> ShapeProfile {
1375        let bucket = BucketConfig { name: "b".to_string(), ..BucketConfig::default() };
1376        let classes = names
1377            .iter()
1378            .map(|n| ClassRule {
1379                name: (*n).to_string(),
1380                bucket: "b".to_string(),
1381                matcher: Matcher::default(),
1382                ..ClassRule::default()
1383            })
1384            .collect();
1385        ShapeProfile::try_new(vec![bucket], classes, QueueConfig::default(), Discipline::Fifo)
1386            .expect("the fixture names its own bucket and its classes are unique")
1387    }
1388
1389    /// No profile means no rows and an all-default snapshot — the claim
1390    /// `interest_none.rs` rests on, checked without a session.
1391    /// *Ablation, recorded:* give the `None` arm of `for_profile` one row
1392    /// (`unwrap_or_else(|| vec![ClassCounters::named(String::new())])`). Every
1393    /// counter is still zero and the compare still reddens, on `classes:
1394    /// [ClassStats { .. }]` against `classes: []` — which is the point: a
1395    /// `ShapeStats` that is *all zeros but shaped* is not `default()`, and the
1396    /// integration gate compares the whole struct.
1397    #[test]
1398    fn an_unshaped_recorder_snapshots_as_default() {
1399        let rec = ShapeRecorder::for_profile(None);
1400        assert_eq!(rec.snapshot(), ShapeStats::default());
1401    }
1402
1403    /// Rows are pre-sized and ordered by the configured class order, not by
1404    /// name and not by first use — a class that never saw a unit is present
1405    /// with a zero row.
1406    ///
1407    /// Order is not decoration: it is what lets the data path address a row
1408    /// by index instead of by name, which is the whole reason the storage
1409    /// is a `Vec` and not a map. The two class names are deliberately in
1410    /// non-alphabetical order, so a snapshot that sorted would redden here
1411    /// too.
1412    ///
1413    /// *Ablation, recorded:* `.rev()` the class iteration in `for_profile`
1414    /// — `left: ["audio", "video"]`, `right: ["video", "audio"]`.
1415    #[test]
1416    fn rows_are_pre_sized_in_configured_order() {
1417        let rec = ShapeRecorder::for_profile(Some(&profile(&["video", "audio"])));
1418        let stats = rec.snapshot();
1419        assert_eq!(
1420            stats.classes.iter().map(|c| c.name.as_str()).collect::<Vec<_>>(),
1421            vec!["video", "audio"],
1422            "snapshot order is the configured order, so a reader can index it"
1423        );
1424        assert!(
1425            stats
1426                .classes
1427                .iter()
1428                .all(|c| *c == ClassStats { name: c.name.clone(), ..ClassStats::default() }),
1429            "a class that saw nothing reports a zero row rather than being absent"
1430        );
1431    }
1432
1433    /// The see-point producer moves exactly two totals and leaves every
1434    /// class row alone.
1435    ///
1436    /// Two calls with different byte counts, so a `bytes_shaped` that
1437    /// counted calls rather than bytes, or that overwrote rather than
1438    /// accumulated, is separable from one that adds: only addition gives
1439    /// `2` and `42` together.
1440    ///
1441    /// The three zero assertions are the boundary the release side moves.
1442    /// Attribution is charged when a unit is *released*, from the same
1443    /// measurement taken here, so a class row moving at the see-point would
1444    /// mean the same byte counted twice on the right-hand side of the
1445    /// conservation identity.
1446    ///
1447    /// *Ablation, recorded:* have `note_object_seen` also bump
1448    /// `default_class.objects_delivered` —
1449    /// `left: ClassStats { .., objects_delivered: 2, .. }` against
1450    /// `right: ClassStats { .., objects_delivered: 0, .. }`.
1451    #[test]
1452    fn note_object_seen_moves_the_session_totals_only() {
1453        let rec = ShapeRecorder::for_profile(Some(&profile(&["video"])));
1454        rec.note_object_seen(ProxySide::ClientToProxy, 40);
1455        rec.note_object_seen(ProxySide::ClientToProxy, 2);
1456        let stats = rec.snapshot();
1457        assert_eq!(stats.objects_seen, 2);
1458        assert_eq!(stats.bytes_shaped, 42);
1459        assert_eq!(stats.classes[0].bytes_delivered, 0, "release is what attributes bytes");
1460        assert_eq!(stats.default_class, ClassStats::default());
1461        assert_eq!(stats.unshapeable, ClassStats::default());
1462    }
1463
1464    /// One recorder, both legs busy: every session total lands on the side
1465    /// it was charged from, and the flat total is the two sides added.
1466    ///
1467    /// This is the shape of a session shaping in both directions, which is
1468    /// the case a single set of totals cannot report: the recorder is
1469    /// shared by every forwarding task, so before the split "5 objects
1470    /// seen" was compatible with 5/0, 0/5 and anything between, and an
1471    /// author diagnosing a stall could not tell which leg had it.
1472    ///
1473    /// Both legs are compared **whole** rather than field by field, so a
1474    /// figure leaking into a neighbouring total on the correct leg reddens
1475    /// too. Every quantity differs between the legs — 2 objects against 3,
1476    /// 49 bytes against 605, one reset against three — so swapping the two
1477    /// rows fails on all of them rather than on none.
1478    ///
1479    /// The sums are stated because they are the guarantee a reader relies on
1480    /// when mixing the two forms; they hold by construction of `snapshot`,
1481    /// which sums the legs rather than keeping a third counter. What the
1482    /// per-leg equalities above them falsify is the attribution, and that
1483    /// is the part no arithmetic guarantees.
1484    ///
1485    /// *Ablation, recorded:* swap the two legs in `snapshot`, so each row is
1486    /// reported under the other's name. Every sum below still passes —
1487    /// addition does not care which order it is given — and the uplink
1488    /// compare reddens with `left: DirectionStats { objects_seen: 3,
1489    /// bytes_shaped: 605, objects_expired: 2, streams_reset_by_shaping: 3,
1490    /// streams_with_mixed_classes: 0 }` against `right: DirectionStats {
1491    /// objects_seen: 2, bytes_shaped: 49, objects_expired: 0,
1492    /// streams_reset_by_shaping: 1, streams_with_mixed_classes: 1 }`. That
1493    /// is the whole case for the per-leg equalities: they are the part the
1494    /// arithmetic cannot check.
1495    ///
1496    /// *Ablation, recorded:* collapse the split instead — have
1497    /// `ShapeRecorder::leg` ignore its argument and always answer
1498    /// `&self.totals[0]`. The uplink compare reddens with `left:
1499    /// DirectionStats { objects_seen: 5, bytes_shaped: 654, objects_expired:
1500    /// 2, streams_reset_by_shaping: 4, streams_with_mixed_classes: 1 }`, and
1501    /// `note_object_seen_moves_the_session_totals_only` goes with it on
1502    /// `left: 4 right: 2` — the aggregate counts the one surviving row
1503    /// twice.
1504    #[test]
1505    fn a_bidirectional_run_charges_each_leg_and_the_legs_sum_to_the_aggregate() {
1506        let rec = ShapeRecorder::for_profile(Some(&profile(&["video", "audio"])));
1507
1508        // Uplink: two objects, a stream header no rule could see, one
1509        // mixed-class stream and one stream the policy gave up on.
1510        rec.note_object_seen(ProxySide::ClientToProxy, 40);
1511        rec.note_object_seen(ProxySide::ClientToProxy, 2);
1512        rec.note_unshapeable_seen(ProxySide::ClientToProxy, 7);
1513        rec.note_mixed_class_stream(ProxySide::ClientToProxy);
1514        rec.note_stream_reset_by_shaping(ProxySide::ClientToProxy);
1515
1516        // Downlink: more of everything, and two expiries the uplink has
1517        // none of.
1518        rec.note_object_seen(ProxySide::RelayToProxy, 100);
1519        rec.note_object_seen(ProxySide::RelayToProxy, 200);
1520        rec.note_object_seen(ProxySide::RelayToProxy, 300);
1521        rec.note_unshapeable_seen(ProxySide::RelayToProxy, 5);
1522        rec.note_expired(ProxySide::RelayToProxy);
1523        rec.note_expired(ProxySide::RelayToProxy);
1524        rec.note_stream_reset_by_shaping(ProxySide::RelayToProxy);
1525        rec.note_stream_reset_by_shaping(ProxySide::RelayToProxy);
1526        rec.note_stream_reset_by_shaping(ProxySide::RelayToProxy);
1527
1528        let stats = rec.snapshot();
1529        assert_eq!(
1530            stats.uplink,
1531            DirectionStats {
1532                objects_seen: 2,
1533                bytes_shaped: 49,
1534                objects_expired: 0,
1535                streams_reset_by_shaping: 1,
1536                streams_with_mixed_classes: 1,
1537            },
1538            "the uplink reports what the uplink was charged, and nothing else"
1539        );
1540        assert_eq!(
1541            stats.downlink,
1542            DirectionStats {
1543                objects_seen: 3,
1544                bytes_shaped: 605,
1545                objects_expired: 2,
1546                streams_reset_by_shaping: 3,
1547                streams_with_mixed_classes: 0,
1548            },
1549            "the downlink's two expiries are its own: a starved downlink must \
1550             not read as a starved uplink"
1551        );
1552
1553        assert_eq!(stats.uplink.objects_seen + stats.downlink.objects_seen, stats.objects_seen);
1554        assert_eq!(stats.uplink.bytes_shaped + stats.downlink.bytes_shaped, stats.bytes_shaped);
1555        assert_eq!(
1556            stats.uplink.objects_expired + stats.downlink.objects_expired,
1557            stats.objects_expired
1558        );
1559        assert_eq!(
1560            stats.uplink.streams_reset_by_shaping + stats.downlink.streams_reset_by_shaping,
1561            stats.streams_reset_by_shaping
1562        );
1563        assert_eq!(
1564            stats.uplink.streams_with_mixed_classes + stats.downlink.streams_with_mixed_classes,
1565            stats.streams_with_mixed_classes
1566        );
1567
1568        // Splitting the totals must not have started attributing anything:
1569        // the class rows are release's, and nothing here released a unit.
1570        assert!(
1571            stats
1572                .classes
1573                .iter()
1574                .all(|c| *c == ClassStats { name: c.name.clone(), ..ClassStats::default() }),
1575            "no class row moved, on either leg"
1576        );
1577        assert_eq!(stats.default_class, ClassStats::default());
1578        assert_eq!(stats.unshapeable, ClassStats::default());
1579    }
1580
1581    /// Every ingress side maps to the leg its traffic is on, and each
1582    /// egress side maps to the same leg as the ingress side it pairs with.
1583    ///
1584    /// The mapping is total because the recorder has no fifth row to put a
1585    /// surprise on: a `ProxySide` that fell through would have to invent a
1586    /// leg, and an invented leg is a byte silently attributed to the wrong
1587    /// side of the session.
1588    #[test]
1589    fn every_side_maps_to_the_leg_its_traffic_travels_on() {
1590        assert_eq!(Direction::from(ProxySide::ClientToProxy), Direction::Uplink);
1591        assert_eq!(Direction::from(ProxySide::ProxyToRelay), Direction::Uplink);
1592        assert_eq!(Direction::from(ProxySide::RelayToProxy), Direction::Downlink);
1593        assert_eq!(Direction::from(ProxySide::ProxyToClient), Direction::Downlink);
1594    }
1595
1596    /// Every side names one connection and one way along it, and the four
1597    /// pairs are all different.
1598    ///
1599    /// The pair is what [`ProxyStats::per_leg`] is indexed by, so a mapping
1600    /// that sent two sides to the same cell would merge two flows with
1601    /// nothing going red anywhere else — the aggregate would still be right.
1602    /// Written as four equalities against the four pairs rather than as a
1603    /// round trip, because the claim is *which* pair, not that some pair
1604    /// exists.
1605    #[test]
1606    fn every_side_names_one_cell_of_the_two_by_two() {
1607        assert_eq!(split(ProxySide::ClientToProxy), (Leg::Client, Direction::Uplink));
1608        assert_eq!(split(ProxySide::ProxyToRelay), (Leg::Upstream, Direction::Uplink));
1609        assert_eq!(split(ProxySide::RelayToProxy), (Leg::Upstream, Direction::Downlink));
1610        assert_eq!(split(ProxySide::ProxyToClient), (Leg::Client, Direction::Downlink));
1611
1612        // Four sides, four cells, no collisions: the property the four lines
1613        // above are for, stated so a fifth arm added later cannot quietly
1614        // land on a cell that is already taken.
1615        let mut cells: Vec<_> = [
1616            ProxySide::ClientToProxy,
1617            ProxySide::ProxyToRelay,
1618            ProxySide::RelayToProxy,
1619            ProxySide::ProxyToClient,
1620        ]
1621        .into_iter()
1622        .map(|side| {
1623            let (leg, direction) = split(side);
1624            (leg_index(leg), direction.index())
1625        })
1626        .collect();
1627        cells.sort_unstable();
1628        cells.dedup();
1629        assert_eq!(cells.len(), 4, "two sides charge the same cell");
1630    }
1631
1632    /// A proxy recorder and one session reporting into it, on the class list
1633    /// `names`.
1634    fn attached(names: &[&str]) -> (Arc<ProxyRecorder>, ShapeRecorder) {
1635        let proxy = Arc::new(ProxyRecorder::new());
1636        let recorder = ShapeRecorder::attached(Some(&profile(names)), Arc::clone(&proxy));
1637        (proxy, recorder)
1638    }
1639
1640    /// A bidirectional run over the class `video`, in which all four
1641    /// crossings carry a different number of bytes and a different number of
1642    /// objects.
1643    ///
1644    /// Uplink: two objects (40 and 2) and a 7-byte stream header arrive from
1645    /// the client; one 40-byte object is dropped by policy, so a 30-byte
1646    /// object and the header go out to the relay. Downlink: four objects
1647    /// (100, 200, 300, 400) and a 5-byte header arrive from the relay, one
1648    /// expires and takes its stream with it, and three objects and the
1649    /// header go out to the client.
1650    ///
1651    /// Every figure differs from every other, in both directions and on both
1652    /// legs, so no swap of two cells and no collapse of either axis can
1653    /// cancel out.
1654    fn bidirectional_run(rec: &ShapeRecorder) {
1655        let video = Class::Rule(0);
1656
1657        // Read from the client.
1658        rec.note_object_seen(ProxySide::ClientToProxy, 40);
1659        rec.note_object_seen(ProxySide::ClientToProxy, 2);
1660        rec.note_unshapeable_seen(ProxySide::ClientToProxy, 7);
1661        rec.note_mixed_class_stream(ProxySide::ClientToProxy);
1662        rec.note_dropped(video, 12);
1663        // Written to the relay. Still the arrival side: turning it around is
1664        // the recorder's job.
1665        rec.note_delivered(ProxySide::ClientToProxy, video, 30);
1666        rec.note_delivered(ProxySide::ClientToProxy, Class::Unshapeable, 7);
1667
1668        // Read from the relay.
1669        rec.note_object_seen(ProxySide::RelayToProxy, 100);
1670        rec.note_object_seen(ProxySide::RelayToProxy, 200);
1671        rec.note_object_seen(ProxySide::RelayToProxy, 300);
1672        rec.note_object_seen(ProxySide::RelayToProxy, 400);
1673        rec.note_unshapeable_seen(ProxySide::RelayToProxy, 5);
1674        rec.note_expired(ProxySide::RelayToProxy);
1675        rec.note_stream_reset_by_shaping(ProxySide::RelayToProxy);
1676        rec.note_dropped(video, 400);
1677        // Written to the client.
1678        rec.note_delivered(ProxySide::RelayToProxy, video, 100);
1679        rec.note_delivered(ProxySide::RelayToProxy, video, 200);
1680        rec.note_delivered(ProxySide::RelayToProxy, video, 300);
1681        rec.note_delivered(ProxySide::RelayToProxy, Class::Unshapeable, 5);
1682    }
1683
1684    /// The four crossings of a proxy land in four different cells, and each
1685    /// cell reports its own crossing.
1686    ///
1687    /// This is the attribution claim, and it is the one no arithmetic
1688    /// checks. A proxy holds two connections and a byte crosses both — read
1689    /// on one leg, written on the other — so leg and direction are
1690    /// genuinely two axes and the surface has four cells to fill. Two
1691    /// mistakes fill them wrongly while leaving every total intact, and
1692    /// either one hides the other: charging every unit to the direction the
1693    /// first arm happened to name, and deriving the leg from the arriving
1694    /// side so that both legs report the same row. Comparing all four rows
1695    /// **whole** is what separates them; comparing one, or comparing a sum,
1696    /// separates neither.
1697    ///
1698    /// The two upstream figures are what the shape exists for. The uplink
1699    /// pair, 49 bytes in against 37 out, is the shaper's own retention on
1700    /// that direction — bytes it read from the client and did not write to
1701    /// the relay — and it is a number that can only be non-zero because the
1702    /// two cells are measured at two different crossings.
1703    ///
1704    /// The three event figures move on the arrival cell only, and the zeros
1705    /// for them on the two departure cells are asserted rather than elided:
1706    /// an expiry is a decision over traffic that came in and never went out,
1707    /// so a departure cell that reported one would be claiming a crossing
1708    /// that did not happen.
1709    ///
1710    /// *Ablation, recorded:* collapse the leg axis — have `leg_index`
1711    /// answer `0` for both legs, so every figure lands on the client row.
1712    /// The first compare reddens with `left: DirectionStats { objects_seen:
1713    /// 3, bytes_shaped: 86, objects_expired: 0, streams_reset_by_shaping: 0,
1714    /// streams_with_mixed_classes: 1 }` against `right: DirectionStats {
1715    /// objects_seen: 2, bytes_shaped: 49, objects_expired: 0,
1716    /// streams_reset_by_shaping: 0, streams_with_mixed_classes: 1 }` — what
1717    /// was written to the relay piled on top of what was read from the
1718    /// client.
1719    ///
1720    /// *Ablation, recorded:* derive the leg from the arriving side instead —
1721    /// name `Leg::Client` in both upstream arms of `split`, which is what
1722    /// that derivation amounts to over the two sides a hook site can hold.
1723    /// The upstream downlink compare reddens with `left: DirectionStats {
1724    /// objects_seen: 3, bytes_shaped: 605, objects_expired: 0,
1725    /// streams_reset_by_shaping: 0, streams_with_mixed_classes: 0 }` against
1726    /// `right: DirectionStats { objects_seen: 4, bytes_shaped: 1005,
1727    /// objects_expired: 1, streams_reset_by_shaping: 1,
1728    /// streams_with_mixed_classes: 0 }` — the two downlink cells swapped,
1729    /// so what this proxy read from the relay is reported as what it sent to
1730    /// the client. Every total still adds up, and every sum still passes.
1731    ///
1732    /// *Ablation, recorded:* collapse the direction axis instead — name
1733    /// `Direction::Uplink` in both downlink arms of `split`. The first
1734    /// compare reddens with `left: DirectionStats { objects_seen: 5,
1735    /// bytes_shaped: 654, objects_expired: 0, streams_reset_by_shaping: 0,
1736    /// streams_with_mixed_classes: 1 }` against `right: DirectionStats {
1737    /// objects_seen: 2, bytes_shaped: 49, objects_expired: 0,
1738    /// streams_reset_by_shaping: 0, streams_with_mixed_classes: 1 }` — both
1739    /// directions piled into row 0.
1740    ///
1741    /// *Ablation, recorded:* keep both axes and drop the turn-around — have
1742    /// `ProxyRecorder::departure` answer `self.arrival(side)`. The first
1743    /// compare reddens with `left: DirectionStats { objects_seen: 3,
1744    /// bytes_shaped: 86, objects_expired: 0, streams_reset_by_shaping: 0,
1745    /// streams_with_mixed_classes: 1 }` against the same right-hand side as
1746    /// the leg collapse above — the two mutations are different mistakes
1747    /// with one observable, because a proxy that cannot tell its legs apart
1748    /// and a proxy that never turns a release around both file a write where
1749    /// the read went.
1750    #[test]
1751    fn each_crossing_charges_its_own_cell_of_the_two_by_two() {
1752        let (proxy, rec) = attached(&["video"]);
1753        bidirectional_run(&rec);
1754        let stats = proxy.snapshot();
1755
1756        assert_eq!(
1757            *stats.leg(Leg::Client).uplink(),
1758            DirectionStats {
1759                objects_seen: 2,
1760                bytes_shaped: 49,
1761                objects_expired: 0,
1762                streams_reset_by_shaping: 0,
1763                streams_with_mixed_classes: 1,
1764            },
1765            "what this proxy read from the client"
1766        );
1767        assert_eq!(
1768            *stats.leg(Leg::Upstream).uplink(),
1769            DirectionStats {
1770                objects_seen: 1,
1771                bytes_shaped: 37,
1772                objects_expired: 0,
1773                streams_reset_by_shaping: 0,
1774                streams_with_mixed_classes: 0,
1775            },
1776            "what it wrote to the relay — 12 bytes short of what it read, \
1777             because a policy dropped an object"
1778        );
1779        assert_eq!(
1780            *stats.leg(Leg::Upstream).downlink(),
1781            DirectionStats {
1782                objects_seen: 4,
1783                bytes_shaped: 1005,
1784                objects_expired: 1,
1785                streams_reset_by_shaping: 1,
1786                streams_with_mixed_classes: 0,
1787            },
1788            "what it read from the relay"
1789        );
1790        assert_eq!(
1791            *stats.leg(Leg::Client).downlink(),
1792            DirectionStats {
1793                objects_seen: 3,
1794                bytes_shaped: 605,
1795                objects_expired: 0,
1796                streams_reset_by_shaping: 0,
1797                streams_with_mixed_classes: 0,
1798            },
1799            "what it wrote to the client"
1800        );
1801    }
1802
1803    /// A stream header crosses both legs as bytes and as no object at all.
1804    ///
1805    /// `objects_seen` is the classifier's count, and the classifier runs
1806    /// where traffic arrives — so on the way in a header is charged to
1807    /// `bytes_shaped` and not to `objects_seen`. The departure cell has to
1808    /// hold the same line or the field means two different things in two
1809    /// cells of the same array, and the difference between the two would
1810    /// read as objects this proxy invented.
1811    ///
1812    /// *Ablation, recorded:* drop the `class != Class::Unshapeable` guard in
1813    /// `note_delivered`, so every released unit counts as an object. The
1814    /// compare reddens with `left: 2` against `right: 1` — the header
1815    /// counted as an object on the way out and not on the way in.
1816    #[test]
1817    fn a_released_header_is_bytes_on_the_departure_cell_and_not_an_object() {
1818        let (proxy, rec) = attached(&["video"]);
1819        rec.note_unshapeable_seen(ProxySide::ClientToProxy, 7);
1820        rec.note_object_seen(ProxySide::ClientToProxy, 40);
1821        rec.note_delivered(ProxySide::ClientToProxy, Class::Unshapeable, 7);
1822        rec.note_delivered(ProxySide::ClientToProxy, Class::Rule(0), 40);
1823
1824        let stats = proxy.snapshot();
1825        let out = stats.leg(Leg::Upstream).uplink();
1826        assert_eq!(out.bytes_shaped, 47, "both units crossed, and both are bytes");
1827        assert_eq!(out.objects_seen, 1, "a header is not an object on the way out either");
1828        assert_eq!(
1829            stats.unshapeable.objects_delivered, 1,
1830            "the unshapeable row still counts the unit it released"
1831        );
1832    }
1833
1834    /// The flat rollup counts a byte where it **entered**, once.
1835    ///
1836    /// Derived rather than accumulated, so it cannot drift from the cells
1837    /// beside it — but *which* cells it is derived from is a real choice and
1838    /// the wrong one is invisible. A byte crosses two legs, so the obvious
1839    /// summation, all four cells, reports every byte twice and looks
1840    /// entirely plausible: it is monotone, it is proportional to the
1841    /// traffic, and it is about double.
1842    ///
1843    /// The compare is over the **whole struct** rather than the fields this
1844    /// test has an opinion about, so a field added to [`SessionStats`] later
1845    /// and derived from the wrong cells cannot slip past it.
1846    ///
1847    /// *Ablation, re-run:* sum all four cells in `ProxyRecorder::snapshot`
1848    /// instead of the two arrival cells. The compare reddens with `left:
1849    /// SessionStats { objects_seen: 10, objects_dropped: 2, objects_expired:
1850    /// 1, streams_reset: 1, bytes_shaped: 1696 }` against `right:
1851    /// SessionStats { objects_seen: 6, .., bytes_shaped: 1054 }` — every byte
1852    /// counted twice, and nothing else out of place.
1853    #[test]
1854    fn the_flat_rollup_counts_a_byte_where_it_entered() {
1855        let (proxy, rec) = attached(&["video"]);
1856        bidirectional_run(&rec);
1857
1858        assert_eq!(
1859            proxy.snapshot().sessions,
1860            SessionStats {
1861                objects_seen: 6,
1862                objects_dropped: 2,
1863                objects_expired: 1,
1864                streams_reset: 1,
1865                bytes_shaped: 1054,
1866            },
1867            "49 + 1005 bytes in, not 49 + 37 + 1005 + 605 crossings"
1868        );
1869    }
1870
1871    /// A session reports both to itself and to its proxy, and the two agree
1872    /// about everything they both hold.
1873    ///
1874    /// The reason the forwarding lives inside the recorder rather than at a
1875    /// second set of call sites: one call charges both or neither, so a
1876    /// producer cannot be added to a session figure and forgotten beside it.
1877    /// The comparison is over the figures the two types share — the flat
1878    /// session totals and the class rows — because those are the ones a
1879    /// reader would put side by side and expect to match.
1880    ///
1881    /// *Ablation, recorded:* drop the proxy forward from
1882    /// `note_object_seen`. The compare reddens with `left: 0` against
1883    /// `right: 6` — the session still reports six objects and the proxy
1884    /// reports none of them.
1885    #[test]
1886    fn one_session_charges_its_own_rows_and_its_proxys_together() {
1887        let (proxy, rec) = attached(&["video"]);
1888        bidirectional_run(&rec);
1889
1890        let session = rec.snapshot();
1891        let aggregate = proxy.snapshot();
1892        assert_eq!(aggregate.sessions.objects_seen, session.objects_seen);
1893        assert_eq!(aggregate.sessions.bytes_shaped, session.bytes_shaped);
1894        assert_eq!(aggregate.sessions.objects_expired, session.objects_expired);
1895        assert_eq!(aggregate.sessions.streams_reset, session.streams_reset_by_shaping);
1896        assert_eq!(aggregate.classes, session.classes);
1897        assert_eq!(aggregate.unshapeable, session.unshapeable);
1898    }
1899
1900    /// A session with no proxy charges no proxy.
1901    ///
1902    /// `ShapeRecorder::for_profile` is what a session constructed directly
1903    /// gets, and such a session belongs to no proxy: it never went through
1904    /// an accept loop, so no [`ProxyStats`] should claim its traffic. The
1905    /// proxy recorder here is built and driven past — the run below charges
1906    /// a recorder that was never attached to it — and it must still snapshot
1907    /// as default.
1908    #[test]
1909    fn a_session_with_no_proxy_leaves_the_aggregate_alone() {
1910        let proxy = Arc::new(ProxyRecorder::new());
1911        let rec = ShapeRecorder::for_profile(Some(&profile(&["video"])));
1912        bidirectional_run(&rec);
1913
1914        assert_ne!(rec.snapshot(), ShapeStats::default(), "the session recorded its own run");
1915        assert_eq!(proxy.snapshot(), ProxyStats::default());
1916    }
1917
1918    /// Class rows are sized by the first shaped session, and a later session
1919    /// on a different class list charges the default row rather than a row
1920    /// named for somebody else's rule.
1921    ///
1922    /// A `Class::Rule(index)` is an index into the class list of the
1923    /// scheduler that produced it. A live `set_shape` that installs a
1924    /// different list reaches sessions accepted afterwards, so the second
1925    /// session here is that session — and if it charged by index anyway,
1926    /// every figure it produced would be reported under the first profile's
1927    /// names, monotone and plausible and wrong. The default row is where a
1928    /// unit whose row cannot be named belongs, which is the answer
1929    /// `ShapeRecorder::row` already gives an index it cannot place.
1930    ///
1931    /// *Ablation, recorded:* have `ProxyRecorder::row` ignore `sized`. The
1932    /// compare reddens with `left: 500` against `right: 100` — the second
1933    /// session's bytes filed under `video`, a class it never had.
1934    #[test]
1935    fn a_session_on_another_class_list_charges_the_default_row() {
1936        let proxy = Arc::new(ProxyRecorder::new());
1937        let first = ShapeRecorder::attached(Some(&profile(&["video"])), Arc::clone(&proxy));
1938        let second = ShapeRecorder::attached(Some(&profile(&["screen"])), Arc::clone(&proxy));
1939
1940        first.note_delivered(ProxySide::ClientToProxy, Class::Rule(0), 100);
1941        second.note_delivered(ProxySide::ClientToProxy, Class::Rule(0), 400);
1942
1943        let stats = proxy.snapshot();
1944        assert_eq!(
1945            stats.classes.iter().map(|c| c.name.as_str()).collect::<Vec<_>>(),
1946            vec!["video"],
1947            "the rows are the first shaped session's, and are never resized"
1948        );
1949        assert_eq!(stats.classes[0].bytes_delivered, 100, "only the matching session's bytes");
1950        assert_eq!(stats.default_class.bytes_delivered, 400, "the mismatched session's go here");
1951    }
1952
1953    /// An unshaped session does not size the proxy's class rows.
1954    ///
1955    /// It has no classes to install, and installing its empty list would
1956    /// leave every shaped session that arrived afterwards mismatched
1957    /// forever — charging the default row for the life of the proxy because
1958    /// the first connection happened to be one nobody configured shaping
1959    /// for. Nothing is lost by skipping it: an unshaped session's writers
1960    /// are all behind a configured profile, so it charges nothing at all.
1961    ///
1962    /// *Ablation, recorded:* let the `None` arm of `ShapeRecorder::attached`
1963    /// adopt an empty class list too. The compare reddens with `left: []`
1964    /// against `right: ["video"]` — the proxy sized itself from a session
1965    /// that had no classes, and the shaped session behind it has no row.
1966    #[test]
1967    fn an_unshaped_session_does_not_size_the_proxys_class_rows() {
1968        let proxy = Arc::new(ProxyRecorder::new());
1969        let _unshaped = ShapeRecorder::attached(None, Arc::clone(&proxy));
1970        let shaped = ShapeRecorder::attached(Some(&profile(&["video"])), Arc::clone(&proxy));
1971
1972        shaped.note_delivered(ProxySide::ClientToProxy, Class::Rule(0), 64);
1973
1974        let stats = proxy.snapshot();
1975        assert_eq!(
1976            stats.classes.iter().map(|c| c.name.as_str()).collect::<Vec<_>>(),
1977            vec!["video"]
1978        );
1979        assert_eq!(stats.classes[0].bytes_delivered, 64);
1980    }
1981
1982    /// An empty class list does not size the proxy's rows either.
1983    ///
1984    /// The sibling of the test above, and the case that one does not cover.
1985    /// The guard beside it keys on there being no profile; this one is about
1986    /// a list that is present and holds nothing.
1987    ///
1988    /// **It reaches [`ProxyRecorder::adopt_classes`] directly, and that is
1989    /// the point of this version of the row.** The way an empty list used to
1990    /// arrive was a `ShapeProfile` with no classes, which
1991    /// [`ShapeProfile::try_new`] accepted because it validated each class it
1992    /// was handed and had nothing to say about being handed none. That
1993    /// constructor now refuses one as `ShapeError::NoClasses`, so there is no
1994    /// profile left that could bring an empty list here and no public path
1995    /// that reaches this branch. The guard stays anyway, and so does this
1996    /// row: what it prevents is proxy-wide, silent and unrecoverable without
1997    /// a restart, and the check is a comparison that was being made in any
1998    /// case.
1999    ///
2000    /// An empty row set matches no later class list, so the proxy's rows
2001    /// would stay empty and every classed session accepted for the rest of
2002    /// its life would charge the default row — every figure right, every
2003    /// label gone, with nothing anywhere reporting it.
2004    ///
2005    /// The 100 bytes are asserted on the row rather than on the total,
2006    /// because a total is exactly what survives the bug: the bytes are never
2007    /// lost, only misfiled.
2008    ///
2009    /// *Ablation, run:* drop the `names.is_empty()` guard from
2010    /// `ProxyRecorder::adopt_classes`, which is the state this file shipped
2011    /// in. The first assertion reddens with
2012    ///
2013    /// ```text
2014    /// thread 'shape::stats::tests::an_empty_class_list_does_not_size_the_proxys_class_rows'
2015    /// (63164) panicked at crates\moqtap-proxy\src\shape\stats.rs:2053:9:
2016    /// a list with no rows in it names no row, so there is nothing for it to
2017    /// charge and nothing is lost by declining it
2018    /// ```
2019    ///
2020    /// The three assertions under it are not reached, so that is the whole of
2021    /// what the mutation was seen to produce; the misfiling they describe is
2022    /// what the empty row set leaves behind once the sizing has been lost.
2023    /// The bug this pins was found by probe rather than by review — a throwaway
2024    /// test printed `classes=[] default_bytes=100` against the shipped code.
2025    #[test]
2026    fn an_empty_class_list_does_not_size_the_proxys_class_rows() {
2027        let proxy = Arc::new(ProxyRecorder::new());
2028        assert!(
2029            !proxy.adopt_classes(&[]),
2030            "a list with no rows in it names no row, so there is nothing for it to charge and \
2031             nothing is lost by declining it"
2032        );
2033        let classed = ShapeRecorder::attached(Some(&profile(&["video"])), Arc::clone(&proxy));
2034
2035        classed.note_delivered(ProxySide::ClientToProxy, Class::Rule(0), 100);
2036
2037        let stats = proxy.snapshot();
2038        assert_eq!(
2039            stats.classes.iter().map(|c| c.name.as_str()).collect::<Vec<_>>(),
2040            vec!["video"],
2041            "the classed session's rows are the proxy's rows: a list with nothing in it must not \
2042             install itself"
2043        );
2044        assert_eq!(stats.classes[0].bytes_delivered, 100, "and its bytes are charged by name");
2045        assert_eq!(
2046            stats.default_class.bytes_delivered, 0,
2047            "not to a row named for nobody, which is where they land once an empty list has won \
2048             the sizing"
2049        );
2050    }
2051
2052    /// A reset zeroes every counter and keeps the class rows, and traffic
2053    /// after it accumulates from zero.
2054    ///
2055    /// The rows survive because they are what a `Class::Rule(index)` means:
2056    /// a reset that dropped them would let the next session install a
2057    /// different class list, which is the relabelling the sizing rule exists
2058    /// to prevent. So the observable is a class row that is present, still
2059    /// named, and empty.
2060    ///
2061    /// *Ablation, recorded:* have `ProxyRecorder::reset` skip the class rows
2062    /// — reset the four cells and stop. The compare reddens with `left:
2063    /// 630` against `right: 0`: the legs read as a proxy that has done
2064    /// nothing while the class rows still hold the whole run.
2065    #[test]
2066    fn a_reset_zeroes_the_counters_and_keeps_the_rows() {
2067        let (proxy, rec) = attached(&["video"]);
2068        bidirectional_run(&rec);
2069        assert_ne!(proxy.snapshot(), ProxyStats::default(), "there was something to clear");
2070
2071        proxy.reset();
2072        let cleared = proxy.snapshot();
2073        assert_eq!(cleared.classes[0].bytes_delivered, 0);
2074        assert_eq!(cleared.classes[0].name, "video", "the row is still the row it was");
2075        assert_eq!(
2076            cleared,
2077            ProxyStats {
2078                classes: vec![ClassStats { name: "video".to_string(), ..ClassStats::default() }],
2079                ..ProxyStats::default()
2080            },
2081            "nothing but the row's name survives a reset"
2082        );
2083
2084        rec.note_object_seen(ProxySide::ClientToProxy, 11);
2085        assert_eq!(proxy.snapshot().sessions.bytes_shaped, 11, "counting resumes from zero");
2086    }
2087}