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}