Skip to main content

moqtap_proxy/
action.rs

1//! What a hook asks the engine to do, and the vocabulary it asks in.
2
3use std::time::Duration;
4
5use bytes::Bytes;
6use tokio_util::sync::CancellationToken;
7
8use crate::shape::StreamKey;
9
10/// What machinery a hook wants armed.
11///
12/// Bitflags-style with no dependency. Read **once per session**, when the
13/// session starts, and cached; a hook that returns a different value later
14/// is not re-consulted. The byte-pump path is chosen *structurally* — which
15/// function `pipe_data` calls — so re-reading per frame would require the
16/// framer to be armed at all times in order to be able to *become*
17/// interested, which is precisely the cost `Interest::NONE` exists to
18/// avoid. Declare the union of everything the hook may ever want here, and
19/// gate at runtime by returning [`Action::Pass`].
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
21pub struct Interest(u8);
22
23impl Interest {
24    /// Pure byte pump: no parsing on any path. Today's fast path,
25    /// bit-for-bit. The default.
26    pub const NONE: Self = Interest(0);
27    /// Parse control messages and honour actions on them. Routes the
28    /// control stream through the slower parse-then-forward pipe, which
29    /// costs per-frame latency.
30    pub const CONTROL: Self = Interest(1 << 0);
31    /// Frame subgroup and fetch objects and honour actions on them. Costs
32    /// whole-object buffering: an object is not forwarded until it is
33    /// buffered whole, or until the framer gives up on it.
34    pub const OBJECTS: Self = Interest(1 << 1);
35    /// Decode and gate datagrams. The hook fires even when the datagram's
36    /// header does not decode.
37    pub const DATAGRAMS: Self = Interest(1 << 2);
38    /// Decide on unidirectional stream open, stream header, and stream end.
39    ///
40    /// **Includes [`Self::OBJECTS`]**, structurally — the bit pattern is
41    /// `(1 << 3) | (1 << 1)`, so `STREAMS.contains(OBJECTS)` is `true` by
42    /// construction rather than by documentation. The header decision only
43    /// exists once the stream is framed, and framing is what `OBJECTS`
44    /// turns on; a `STREAMS`-only hook that did not imply it would take
45    /// `pipe_data_passthrough` and never see a header at all.
46    ///
47    /// A hook that wants stream decisions but no object decisions still
48    /// declares `STREAMS` and simply returns [`Action::Pass`] from
49    /// [`ProxyHook::on_object`](crate::hook::ProxyHook::on_object) — it
50    /// pays for framing either way, because there is no header without it.
51    ///
52    /// ```
53    /// use moqtap_proxy::action::Interest;
54    /// assert!(Interest::STREAMS.contains(Interest::OBJECTS));
55    /// ```
56    pub const STREAMS: Self = Interest((1 << 3) | (1 << 1));
57
58    /// Whether every flag in `other` is set here.
59    pub const fn contains(self, other: Self) -> bool {
60        self.0 & other.0 == other.0
61    }
62    /// The union of two interests.
63    pub const fn union(self, other: Self) -> Self {
64        Interest(self.0 | other.0)
65    }
66    /// Whether no flag is set.
67    pub const fn is_none(self) -> bool {
68        self.0 == 0
69    }
70}
71
72// The implication `STREAMS ⊇ OBJECTS` is what makes `session.rs`'s
73// `objects_enabled` true for a `STREAMS`-only hook. A revision that
74// wrote `STREAMS` as a plain `1 << 3` would send that hook down
75// `pipe_data_passthrough`, where `on_stream_header` can never fire — a
76// silent no-op rather than a refusal. Fail the build instead.
77const _: () = assert!(Interest::STREAMS.contains(Interest::OBJECTS));
78
79impl std::ops::BitOr for Interest {
80    type Output = Self;
81    fn bitor(self, rhs: Self) -> Self {
82        self.union(rhs)
83    }
84}
85
86/// A cheap, clonable release handle the engine awaits.
87///
88/// Level-triggered: releasing before anyone waits is observed by every
89/// later waiter, so there is no lost-wakeup race. All clones share one
90/// release, and the handle is eight bytes.
91///
92/// The engine never awaits a gate on its own — it always races the gate
93/// against session cancellation and [`EgressConfig::max_hold`], so a hook
94/// that never releases cannot make a stream unkillable.
95///
96/// Deliberately a newtype rather than an exposed `CancellationToken`:
97/// handing the engine the session's own cancel token would conflate
98/// teardown with object release.
99#[derive(Debug, Clone, Default)]
100pub struct Gate(CancellationToken);
101
102impl Gate {
103    /// A new, unreleased gate.
104    pub fn new() -> Self {
105        Self::default()
106    }
107    /// Release every holder. Idempotent.
108    pub fn release(&self) {
109        self.0.cancel();
110    }
111    /// Whether [`Self::release`] has been called.
112    pub fn is_released(&self) -> bool {
113        self.0.is_cancelled()
114    }
115    /// Resolve once the gate is released. Engine-internal: callers race it
116    /// against session cancellation and [`EgressConfig::max_hold`].
117    pub(crate) async fn wait(&self) {
118        self.0.cancelled().await;
119    }
120}
121
122/// What the engine should do with the unit of traffic the hook was shown.
123///
124/// [`Self::Delay`] and [`Self::Hold`] are *modifiers*: they carry the
125/// action to perform once the unit is released. Their `then` must be a
126/// content action — [`Self::Pass`], [`Self::Replace`],
127/// [`Self::ReplacePayload`] or [`Self::Drop`]. Any other nesting is refused
128/// with
129/// [`Refusal::WrongComposition`](crate::capability::Refusal::WrongComposition)
130/// rather than silently ignored, so `Delay { then: ResetStream { .. } }`
131/// and `Delay { then: Truncate { .. } }` (terminals — positional by
132/// construction, so the queue already orders them and a delay would only
133/// move the end of the stream), `Delay { then: CloseSession { .. } }` (a
134/// session-scoped decision, with no per-unit release to attach it to) and
135/// `Delay { then: Delay { .. } }` (a nested modifier) are all loud. Those
136/// four, and only those four, are the refused shapes; the three `detail`
137/// strings in `exec.rs` enumerate them.
138///
139/// # `Delay { then: Drop(_) }` is admitted, and is not inert
140///
141/// A previous revision of this paragraph named it as a refused shape and
142/// called it "unobservable". Both halves were wrong, and they also
143/// contradicted the sentence above them, which lists `Drop` as a legal
144/// inner action. The engine's behaviour is the correct one and the doc has
145/// been brought to it.
146///
147/// It is not unobservable. A deferred drop takes an **ordering slot** in
148/// the stream's pending queue for the whole of `by`, and the queue only
149/// ever writes from its front — so nothing the hook decides after it can
150/// reach the wire until it is released. So
151/// `Action::Drop(DropMode::Elide).delayed(d)` deletes this object *and*
152/// head-of-line-blocks everything after it on that stream for `d`: one
153/// decision, two effects, both on the wire. That is a scenario worth
154/// expressing — a relay that loses an object and stalls while it notices —
155/// and refusing it would take it away. `Hold { then: Drop(_) }` is the
156/// same impairment under a [`Gate`] instead of a clock.
157///
158/// It follows that this composition is **not** a way to write a
159/// deliberately inert guard. `Drop` deletes the unit wherever it is
160/// admitted, wrapped or not; a hook that wants the unit forwarded
161/// untouched returns [`Self::Pass`], which is the only action that
162/// promises that.
163#[derive(Debug, Clone)]
164#[non_exhaustive]
165pub enum Action {
166    /// Forward unchanged.
167    ///
168    /// On the object site this forwards the framer's own `Bytes` slice —
169    /// never a re-encode, on any draft. That is what makes a no-action
170    /// session byte-identical to the byte pump.
171    Pass,
172    /// Replace the whole unit's wire bytes.
173    ///
174    /// Valid at the control and datagram sites, where the unit is
175    /// self-delimiting and the hook owns all of it. Refused at the object
176    /// site with [`Refusal::WrongSite`](crate::capability::Refusal::WrongSite):
177    /// replacing a whole wire object would require the hook to encode the
178    /// draft's object framing, which is the knowledge this crate exists to
179    /// hide. Use [`Self::ReplacePayload`].
180    Replace(Bytes),
181    /// Replace an object's or a datagram's payload, keeping its framing.
182    ///
183    /// **At the object site.** The replacement must be exactly
184    /// [`ObjectMeta::payload_len`](crate::framer::ObjectMeta::payload_len)
185    /// bytes; a different length is refused with
186    /// [`Refusal::LengthChanged`](crate::capability::Refusal::LengthChanged)
187    /// rather than mis-framed. The engine splices at
188    /// `raw.len() - meta.payload_len`, which is the payload offset on every
189    /// draft and both stream kinds.
190    ///
191    /// Refused when the object carries a status
192    /// ([`ObjectMeta::status`](crate::framer::ObjectMeta::status) is
193    /// `Some`), because a status object has no payload slot.
194    ///
195    /// **At the datagram site**, where it is gated on
196    /// [`Precondition::DatagramPayloadDelimited`](crate::capability::Precondition::DatagramPayloadDelimited):
197    /// the engine splices after the decoded header, at
198    /// `data.len() - cursor.len()`. Refused with
199    /// [`Refusal::PayloadNotDelimited`](crate::capability::Refusal::PayloadNotDelimited)
200    /// in the three cases where no such offset exists — on **draft-14**,
201    /// whose `AnyDatagramHeader` decode consumes the payload; on a **status
202    /// datagram**, which has no payload slot; and when the **header did
203    /// not decode**, where the hook still fires but there is nothing to
204    /// splice after. See
205    /// [`ProxyHook::on_datagram`](crate::hook::ProxyHook::on_datagram),
206    /// whose rustdoc says the same thing from the caller's side.
207    ///
208    /// Refused with
209    /// [`Refusal::WrongSite`](crate::capability::Refusal::WrongSite)
210    /// everywhere else — the control site's unit has no payload/framing
211    /// split the proxy may assume, and the stream sites take a
212    /// [`StreamAction`].
213    ReplacePayload(Bytes),
214    /// Release `then` no earlier than `arrived_at + by`.
215    ///
216    /// A **deadline**, not a spacing: two objects that arrive together,
217    /// each with `Delay { by: 100ms }`, are both released about 100 ms
218    /// later — not at +100 ms and +200 ms. Release times are clamped
219    /// monotonically against the queue's tail, so a later unit can never
220    /// overtake an earlier one regardless of its delay.
221    ///
222    /// Reads continue while units wait, so this is a latency shift rather
223    /// than a rate limit — until the stream's pending queue reaches
224    /// [`EgressConfig::max_pending_bytes`], at which point reads stop and
225    /// the delay becomes backpressure. That transition is reported once as
226    /// `ProxyEvent::Impairment { kind: EgressQueueFull }`.
227    ///
228    /// `by` is clamped to [`EgressConfig::max_hold`]. A clamp is reported
229    /// as `ProxyEvent::Impairment { kind: HoldClamped { .. } }`.
230    ///
231    /// # Resolution
232    ///
233    /// Release timing does **not** use `tokio::time::sleep`, which is
234    /// bounded below by the ~15.6 ms Windows system tick — as is every
235    /// other interruptible wait in `std`. Releases are driven by a
236    /// process-wide release wheel on one dedicated OS thread, measured at
237    /// **p50 0.11-0.17 ms, p95 0.52-0.57 ms end-to-end on Windows 11**
238    /// against 11-15 ms for
239    /// `tokio::time::sleep`. The measured lateness of every deferred
240    /// release is reported in
241    /// [`crate::instrument::Counters::release_errors`]; assert on it
242    /// rather than assuming the delay was honoured.
243    ///
244    /// Three consequences worth knowing:
245    ///
246    /// * When `MOQTAP_RELEASE_TIMER` forces the coarse backend, the floor
247    ///   returns to ~15.6 ms on Windows, and the session
248    ///   says so exactly once with
249    ///   `ProxyEvent::Impairment { kind: CoarseReleaseTimer { .. } }`.
250    ///   A run that could not honour its own delays is never silent about
251    ///   it. See [`crate::instrument::release_timer_backend`].
252    /// * The wheel is on a real clock, not tokio's. A test using
253    ///   `#[tokio::test(start_paused = true)]` and expecting a `Delay` to
254    ///   complete will **wait out the real deadline**, not advance
255    ///   virtual time. Do not pause time around `Delay` or `Hold`.
256    /// * The wheel's thread is created on the first deferred release in
257    ///   the process and is never joined (it lives in a `static
258    ///   OnceLock`). Leak detectors and thread counters will see one live
259    ///   thread and one leaked allocation after any delaying test. A
260    ///   session that never delays never creates it —
261    ///   [`crate::instrument::release_timer_started`] is the falsifiable
262    ///   form of that claim.
263    Delay {
264        /// How long to hold the unit past its arrival.
265        by: Duration,
266        /// What to do once it is released.
267        then: Box<Action>,
268    },
269    /// Release `then` when `gate` is released, at
270    /// [`EgressConfig::max_hold`], or at session teardown — whichever comes
271    /// first.
272    Hold {
273        /// The release handle.
274        gate: Gate,
275        /// What to do once it is released.
276        then: Box<Action>,
277    },
278    /// Remove the unit from the wire. See [`DropMode`].
279    Drop(DropMode),
280    /// Write the first `bytes` bytes of this unit, then reset the stream.
281    ///
282    /// Positional: everything queued ahead of it is written first, then
283    /// the truncated prefix, then `RESET_STREAM` with `code`.
284    /// `code` is stated rather than defaulted, exactly as in
285    /// [`Self::ResetStream`]. A truncation is a *simulated* publisher
286    /// abandonment and the code is the whole of what the scenario is
287    /// simulating: `0x0` INTERNAL_ERROR reads as "the proxy did this", `0x2`
288    /// DELIVERY_TIMEOUT reads as *a relay hit its delivery timeout*, and the
289    /// two make a subscriber take different paths. There is no default that is
290    /// right for both, so the type asks. The same range check as
291    /// [`Self::ResetStream`] applies:
292    /// [`Refusal::ErrorCodeOutOfRange`](crate::capability::Refusal::ErrorCodeOutOfRange)
293    /// above 2^62 - 1, before anything is sent. On drafts 07-10, which define
294    /// no stream-reset code vocabulary at all, the reset still executes and
295    /// `Effect::Truncated` reports `code_defined: false`.
296    /// The peer observes **at most** `bytes` further bytes, and may observe
297    /// none. quinn clears the receive assembler the moment `RESET_STREAM` is
298    /// processed (`quinn-proto/src/connection/streams/recv.rs`: **Nuke buffers
299    /// so that future reads fail immediately**), so only bytes the peer
300    /// application had already read out survive; `reset()` additionally
301    /// discards anything still in the local send buffer. Assert an upper bound
302    /// and a prefix, never an exact count.
303    ///
304    /// Refused on control streams on every draft, and on datagrams.
305    Truncate {
306        /// How many bytes of this unit to write before resetting.
307        bytes: usize,
308        /// The application error code for the reset that follows.
309        code: u64,
310    },
311    /// Reset the destination stream with this application error code.
312    ///
313    /// Refused on control streams on every draft: resetting a control
314    /// stream at the transport layer is a session-level `PROTOCOL_VIOLATION`
315    /// in all of drafts 07-19. The documented escalation is
316    /// [`Self::CloseSession`].
317    ///
318    /// Codes above the QUIC varint ceiling (2^62 - 1) are refused with
319    /// [`Refusal::ErrorCodeOutOfRange`](crate::capability::Refusal::ErrorCodeOutOfRange)
320    /// before anything is sent, rather than silently becoming a FIN.
321    ResetStream {
322        /// The application error code to send.
323        code: u64,
324    },
325    /// Close both legs of the session with this code and reason.
326    ///
327    /// `code` is a *session termination* code, a different namespace from
328    /// a stream reset code: `0x0` NO_ERROR, `0x1` INTERNAL_ERROR, `0x2`
329    /// UNAUTHORIZED, `0x3` PROTOCOL_VIOLATION. Note that session
330    /// INTERNAL_ERROR is `0x1` while stream-reset INTERNAL_ERROR is `0x0`.
331    ///
332    /// Honoured at **every** site that returns an [`Action`], including
333    /// [`Site::StreamEnd`](crate::capability::Site::StreamEnd) on a data
334    /// stream *and* on the control stream. A close is session-scoped, so
335    /// no site can be the wrong one for it: unlike
336    /// [`Self::ResetStream`], there is no per-stream object it needs and
337    /// nothing left to forward that honouring it could corrupt. It is the
338    /// documented escalation for the two sites where a stream reset is a
339    /// protocol violation.
340    ///
341    /// The first close request wins; later ones are refused with
342    /// [`Refusal::SessionAlreadyClosing`](crate::capability::Refusal::SessionAlreadyClosing).
343    CloseSession {
344        /// The session termination code.
345        code: u32,
346        /// The reason phrase.
347        reason: Bytes,
348    },
349}
350
351impl Action {
352    /// Wrap this action in a [`Action::Delay`].
353    pub fn delayed(self, by: Duration) -> Self {
354        Action::Delay { by, then: Box::new(self) }
355    }
356    /// Wrap this action in a [`Action::Hold`].
357    pub fn held(self, gate: Gate) -> Self {
358        Action::Hold { gate, then: Box::new(self) }
359    }
360}
361
362/// How [`Action::Drop`] removes an object.
363///
364/// `MarkMissing` — a zero-length object carrying status
365/// `ObjectDoesNotExist`, the obvious default to reach for — is **not in
366/// this enum in 0.4.0**. That status was removed from the registry in
367/// draft-17, and drafts 15-19 do not validate the status on write, so a
368/// naive implementation emits a code point those drafts do not define, on a
369/// stream that round-trips green against itself. Dropping an object
370/// therefore always removes its bytes; nothing here can leave a tombstone
371/// behind.
372///
373/// The absence is a compile-time fact, not a promise:
374///
375/// ```compile_fail
376/// use moqtap_proxy::action::DropMode;
377/// // drop_mark_missing_is_not_constructible: no such variant in 0.4.0.
378/// let _mode = DropMode::MarkMissing;
379/// ```
380///
381/// The companion example below is what makes that `compile_fail` block
382/// mean something: if the path or the import were wrong, the block would
383/// still "pass" for the wrong reason, and this one would go red.
384///
385/// ```
386/// use moqtap_proxy::action::{Action, DropMode};
387/// let mode = DropMode::Elide;
388/// assert!(matches!(mode, DropMode::Elide));
389/// let _action = Action::Drop(mode);
390/// ```
391#[derive(Debug, Clone, Copy, PartialEq, Eq)]
392#[non_exhaustive]
393pub enum DropMode {
394    /// Remove the object's bytes from the wire, preserving every survivor's
395    /// **absolute** object ID.
396    ///
397    /// Drafts 07-13, and fetch streams on drafts 07-14, encode absolute
398    /// IDs, so this is pure byte deletion: every survivor is forwarded
399    /// verbatim, including any non-minimally-encoded varint it arrived
400    /// with. Drafts 14-19 delta-encode, so the *one* object following an
401    /// elided run has its leading ID varint rewritten and nothing else;
402    /// every later object is again forwarded verbatim, because the wire
403    /// cursor re-converges after that one fix-up.
404    ///
405    /// Eliding `2` from `0,1,2,3,4` yields a stream decoding to `0,1,3,4` —
406    /// not `0,1,2,3`.
407    ///
408    /// Refused when the object is index 0 of a stream whose subgroup ID is
409    /// defined as the first object's ID
410    /// ([`ObjectMeta::subgroup_id`](crate::framer::ObjectMeta::subgroup_id)
411    /// is `None`), because removing it silently redefines the subgroup ID
412    /// for the receiver. Also refused when the object carries a status,
413    /// which is conservatively treated as a boundary marker.
414    Elide,
415}
416
417/// What to do with a unidirectional stream.
418///
419/// Returned from both
420/// [`ProxyHook::on_stream_open`](crate::hook::ProxyHook::on_stream_open)
421/// and
422/// [`ProxyHook::on_stream_header`](crate::hook::ProxyHook::on_stream_header).
423/// The observable effect of `Reject` differs between the two, and both are
424/// honest:
425///
426/// * At open, no peer stream is created at all.
427/// * At header, the peer stream already exists — it is opened before any
428///   byte of the source is read, whether that is in the accept loop or, for
429///   a stream whose open was deferred by [`Self::OpenAfter`], in the
430///   stream's own task once the delay has elapsed — so it is reset with
431///   `code` having carried zero payload bytes, and the source is stopped
432///   with `code`.
433///
434/// The same asymmetry decides where [`Self::OpenAfter`] is legal: it is a
435/// decision about *when the peer stream comes into existence*, so only the
436/// open site can take it. [`Self::SerializeAfter`] is a decision about when
437/// the first **byte** is written, which both sites can still take.
438#[derive(Debug, Clone, Copy, PartialEq, Eq)]
439#[non_exhaustive]
440pub enum StreamAction {
441    /// Forward the stream normally.
442    Open,
443    /// Do not forward this stream. The source is stopped with `code`.
444    Reject {
445        /// The application error code.
446        code: u64,
447    },
448    /// Forward the stream, but do not open the peer stream until `after`
449    /// has elapsed.
450    ///
451    /// **Valid at
452    /// [`ProxyHook::on_stream_open`](crate::hook::ProxyHook::on_stream_open)
453    /// only.** By
454    /// [`ProxyHook::on_stream_header`](crate::hook::ProxyHook::on_stream_header)
455    /// the peer stream already exists — it is opened before the first
456    /// source byte is read, which is what makes a header arrive at all — so
457    /// there is nothing left to defer and the header site refuses it with
458    /// [`Refusal::WrongSite`](crate::capability::Refusal::WrongSite),
459    /// reported as `ProxyEvent::ActionRefused`. It is refused rather than
460    /// quietly ignored: a hook that asks for a deferral this crate cannot
461    /// perform is told so, and the stream is forwarded unchanged.
462    /// Delaying a stream's *opening* on something only its header reveals
463    /// — its track alias, say — would want the header site, and is
464    /// therefore not expressible in this release: the open decision has to
465    /// be taken before a byte is read.
466    ///
467    /// This models a relay that is slow to accept a subscription rather
468    /// than one that is slow to send: the subscriber observes no stream at
469    /// all for `after`, not an open-but-idle one. For the latter, see
470    /// [`Self::SerializeAfter`], which *is* valid at both sites.
471    ///
472    /// The deferral is a wire-visible one, not a bookkeeping note: the peer
473    /// stream is not opened and the source is not read until `after` has
474    /// elapsed, so a stream opened later and not deferred reaches the peer
475    /// first.
476    OpenAfter(Duration),
477    /// Open the peer stream now, but write nothing on it until the stream
478    /// named by the key has ended.
479    ///
480    /// Head-of-line simulation: two streams that a relay would have
481    /// interleaved are forced into sequence, so a scenario can reproduce a
482    /// subscriber that stalls behind an unrelated group.
483    ///
484    /// Valid at **both** stream sites — it defers the first write, not the
485    /// stream's existence.
486    ///
487    /// One stream is exempt, and it is worth knowing before writing a
488    /// scenario against it: on the drafts whose control plane is a pair of
489    /// unidirectional streams, a stream that turns out to *be* one of them
490    /// is not held. Holding a control stream's first write would hold
491    /// SETUP, and the session with it. The open site cannot tell in
492    /// advance — which stream it is is decided by the first varint on it,
493    /// read after the decision has been taken — so the decision is
494    /// admitted and then does not apply to that one stream.
495    ///
496    /// The key comes from
497    /// [`StreamCtx::key`](crate::hook::StreamCtx::key) on a stream the hook
498    /// was shown earlier. A key naming a stream that has already ended, or
499    /// that never existed in this session, **proceeds immediately** and
500    /// reports `Impairment { SerializeTargetUnknown }` once — a scenario
501    /// cannot deadlock a stream by naming the wrong one, and the mistake is
502    /// reported rather than silently waited out.
503    SerializeAfter(StreamKey),
504}
505
506/// How a stream ended.
507#[derive(Debug, Clone, Copy, PartialEq, Eq)]
508#[non_exhaustive]
509pub enum StreamEnd {
510    /// The source finished the stream cleanly.
511    Fin,
512    /// The source peer sent `RESET_STREAM`.
513    Reset {
514        /// The peer's application error code.
515        code: u64,
516    },
517    /// The destination peer sent `STOP_SENDING`.
518    Stopped {
519        /// The peer's application error code.
520        code: u64,
521    },
522    /// The session was cancelled while the stream was open.
523    Cancelled,
524}
525
526/// Engine-side knobs for action execution. Carried on the session config.
527#[derive(Debug, Clone, Copy, PartialEq, Eq)]
528#[non_exhaustive]
529pub struct EgressConfig {
530    /// Bytes a per-stream pending queue may hold before the read side is
531    /// stalled. Past this point `Delay` and `Hold` are backpressure rather
532    /// than latency, and the transition is reported once per stream.
533    /// Default 1 MiB.
534    pub max_pending_bytes: usize,
535    /// Ceiling on any single [`Action::Hold`] and on any single
536    /// [`Action::Delay`]. A clamped delay is reported. Default 30 s.
537    ///
538    /// The ceiling is an `Instant` deadline, and `Instant` does not
539    /// behave the same way across a machine suspend on every platform: a
540    /// 30 s hold armed before a laptop sleeps may fire immediately on
541    /// resume (Windows `QueryPerformanceCounter`) or 30 s after resume
542    /// (Linux `CLOCK_MONOTONIC`). Irrelevant on CI; surprising when
543    /// debugging a scenario on a laptop.
544    pub max_hold: Duration,
545    /// How long a requested close gives this session's egress queues to
546    /// flush before both legs are closed anyway. Default 100 ms.
547    ///
548    /// Only [`ProxyControl::close_session`](crate::control::ProxyControl::close_session)
549    /// reads this. Every other way a session ends — a peer going away, a
550    /// hook's [`Action::CloseSession`], the proxy being cancelled — tears
551    /// down at once and has always done so. A requested close is different
552    /// because somebody is waiting for it to mean something: closing the
553    /// instant the request lands discards whatever a `Delay` or a `Hold`
554    /// was still holding, and the caller cannot tell that from a session
555    /// that had nothing queued.
556    ///
557    /// The bound is the point. An unbounded drain turns a close into a call
558    /// that may never finish: a stream whose destination peer has stopped
559    /// reading never empties its queue, and a shaped class whose bucket is
560    /// dry empties it only at the configured rate. Whatever is still queued
561    /// when this elapses is discarded and reported as
562    /// [`ImpairmentKind::QueuedBytesAtTeardown`](crate::event::ImpairmentKind::QueuedBytesAtTeardown),
563    /// so bytes that did not make it are named rather than lost quietly,
564    /// and the close still carries the code that was asked for.
565    ///
566    /// # The default is a guess, and here is the measurement that replaces it
567    ///
568    /// 100 ms was chosen because it is long enough for a loopback flush and
569    /// short enough that a caller closing sessions in a loop does not
570    /// notice, not because anything was measured. To replace it: pin one
571    /// queue state — a fixed object size, a fixed unit count, a fixed
572    /// destination window, written down beside the figure — sweep this
573    /// timeout across that state, and take the knee at which the **stranded
574    /// byte count reaches zero**. Quote the queue state with the number; a
575    /// knee measured against 4 KiB objects says nothing about 256 KiB ones.
576    ///
577    /// Measure stranded *bytes*, never elapsed time. Timing the drain
578    /// measures the fixture that filled the queue — how fast the source
579    /// wrote, how the destination's flow-control window happened to open —
580    /// and a timeout tuned against it is tuned against the harness. The
581    /// byte count is the thing that is either zero or not.
582    pub drain_timeout: Duration,
583}
584
585impl Default for EgressConfig {
586    fn default() -> Self {
587        Self {
588            max_pending_bytes: 1024 * 1024,
589            max_hold: Duration::from_secs(30),
590            drain_timeout: Duration::from_millis(100),
591        }
592    }
593}
594
595#[cfg(test)]
596mod tests {
597    use super::*;
598
599    #[test]
600    fn streams_contains_objects_structurally() {
601        assert!(Interest::STREAMS.contains(Interest::OBJECTS));
602        assert!(!Interest::STREAMS.contains(Interest::CONTROL));
603        assert!(!Interest::STREAMS.contains(Interest::DATAGRAMS));
604    }
605
606    #[test]
607    fn none_is_the_default_and_contains_nothing() {
608        assert_eq!(Interest::default(), Interest::NONE);
609        assert!(Interest::NONE.is_none());
610        assert!(!Interest::OBJECTS.is_none());
611        assert!(Interest::NONE.contains(Interest::NONE));
612        assert!(!Interest::NONE.contains(Interest::OBJECTS));
613    }
614
615    #[test]
616    fn union_and_bitor_agree() {
617        let a = Interest::CONTROL | Interest::DATAGRAMS;
618        assert_eq!(a, Interest::CONTROL.union(Interest::DATAGRAMS));
619        assert!(a.contains(Interest::CONTROL));
620        assert!(a.contains(Interest::DATAGRAMS));
621        assert!(!a.contains(Interest::OBJECTS));
622    }
623
624    #[test]
625    fn a_gate_is_level_triggered() {
626        let g = Gate::new();
627        assert!(!g.is_released());
628        let clone = g.clone();
629        g.release();
630        assert!(g.is_released());
631        assert!(clone.is_released(), "clones share one release");
632        g.release();
633        assert!(g.is_released(), "release is idempotent");
634    }
635
636    #[tokio::test]
637    async fn waiting_on_an_already_released_gate_returns_immediately() {
638        let g = Gate::new();
639        g.release();
640        g.wait().await;
641    }
642
643    #[test]
644    fn modifiers_wrap_the_action_they_are_given() {
645        let a = Action::Pass.delayed(Duration::from_millis(5));
646        match a {
647            Action::Delay { by, then } => {
648                assert_eq!(by, Duration::from_millis(5));
649                assert!(matches!(*then, Action::Pass));
650            }
651            other => panic!("expected Delay, got {other:?}"),
652        }
653        let h = Action::Drop(DropMode::Elide).held(Gate::new());
654        match h {
655            Action::Hold { gate, then } => {
656                assert!(!gate.is_released());
657                assert!(matches!(*then, Action::Drop(DropMode::Elide)));
658            }
659            other => panic!("expected Hold, got {other:?}"),
660        }
661    }
662
663    #[test]
664    fn truncate_carries_its_own_reset_code() {
665        let t = Action::Truncate { bytes: 7, code: 0x2 };
666        match t {
667            Action::Truncate { bytes, code } => {
668                assert_eq!(bytes, 7);
669                assert_eq!(code, 0x2);
670            }
671            other => panic!("expected Truncate, got {other:?}"),
672        }
673    }
674
675    #[test]
676    fn egress_defaults_are_one_mib_and_thirty_seconds() {
677        let c = EgressConfig::default();
678        assert_eq!(c.max_pending_bytes, 1024 * 1024);
679        assert_eq!(c.max_hold, Duration::from_secs(30));
680    }
681}