#[non_exhaustive]pub enum Action {
Pass,
Replace(Bytes),
ReplacePayload(Bytes),
Delay {
by: Duration,
then: Box<Action>,
},
Hold {
gate: Gate,
then: Box<Action>,
},
Drop(DropMode),
Truncate {
bytes: usize,
code: u64,
},
ResetStream {
code: u64,
},
CloseSession {
code: u32,
reason: Bytes,
},
}Expand description
What the engine should do with the unit of traffic the hook was shown.
Self::Delay and Self::Hold are modifiers: they carry the
action to perform once the unit is released. Their then must be a
content action — Self::Pass, Self::Replace,
Self::ReplacePayload or Self::Drop. Any other nesting is refused
with
Refusal::WrongComposition
rather than silently ignored, so Delay { then: ResetStream { .. } }
and Delay { then: Truncate { .. } } (terminals — positional by
construction, so the queue already orders them and a delay would only
move the end of the stream), Delay { then: CloseSession { .. } } (a
session-scoped decision, with no per-unit release to attach it to) and
Delay { then: Delay { .. } } (a nested modifier) are all loud. Those
four, and only those four, are the refused shapes; the three detail
strings in exec.rs enumerate them.
§Delay { then: Drop(_) } is admitted, and is not inert
A previous revision of this paragraph named it as a refused shape and
called it “unobservable”. Both halves were wrong, and they also
contradicted the sentence above them, which lists Drop as a legal
inner action. The engine’s behaviour is the correct one and the doc has
been brought to it.
It is not unobservable. A deferred drop takes an ordering slot in
the stream’s pending queue for the whole of by, and the queue only
ever writes from its front — so nothing the hook decides after it can
reach the wire until it is released. So
Action::Drop(DropMode::Elide).delayed(d) deletes this object and
head-of-line-blocks everything after it on that stream for d: one
decision, two effects, both on the wire. That is a scenario worth
expressing — a relay that loses an object and stalls while it notices —
and refusing it would take it away. Hold { then: Drop(_) } is the
same impairment under a Gate instead of a clock.
It follows that this composition is not a way to write a
deliberately inert guard. Drop deletes the unit wherever it is
admitted, wrapped or not; a hook that wants the unit forwarded
untouched returns Self::Pass, which is the only action that
promises that.
Variants (Non-exhaustive)§
This enum is marked as non-exhaustive
Pass
Forward unchanged.
On the object site this forwards the framer’s own Bytes slice —
never a re-encode, on any draft. That is what makes a no-action
session byte-identical to the byte pump.
Replace(Bytes)
Replace the whole unit’s wire bytes.
Valid at the control and datagram sites, where the unit is
self-delimiting and the hook owns all of it. Refused at the object
site with Refusal::WrongSite:
replacing a whole wire object would require the hook to encode the
draft’s object framing, which is the knowledge this crate exists to
hide. Use Self::ReplacePayload.
ReplacePayload(Bytes)
Replace an object’s or a datagram’s payload, keeping its framing.
At the object site. The replacement must be exactly
ObjectMeta::payload_len
bytes; a different length is refused with
Refusal::LengthChanged
rather than mis-framed. The engine splices at
raw.len() - meta.payload_len, which is the payload offset on every
draft and both stream kinds.
Refused when the object carries a status
(ObjectMeta::status is
Some), because a status object has no payload slot.
At the datagram site, where it is gated on
Precondition::DatagramPayloadDelimited:
the engine splices after the decoded header, at
data.len() - cursor.len(). Refused with
Refusal::PayloadNotDelimited
in the three cases where no such offset exists — on draft-14,
whose AnyDatagramHeader decode consumes the payload; on a status
datagram, which has no payload slot; and when the header did
not decode, where the hook still fires but there is nothing to
splice after. See
ProxyHook::on_datagram,
whose rustdoc says the same thing from the caller’s side.
Refused with
Refusal::WrongSite
everywhere else — the control site’s unit has no payload/framing
split the proxy may assume, and the stream sites take a
StreamAction.
Delay
Release then no earlier than arrived_at + by.
A deadline, not a spacing: two objects that arrive together,
each with Delay { by: 100ms }, are both released about 100 ms
later — not at +100 ms and +200 ms. Release times are clamped
monotonically against the queue’s tail, so a later unit can never
overtake an earlier one regardless of its delay.
Reads continue while units wait, so this is a latency shift rather
than a rate limit — until the stream’s pending queue reaches
EgressConfig::max_pending_bytes, at which point reads stop and
the delay becomes backpressure. That transition is reported once as
ProxyEvent::Impairment { kind: EgressQueueFull }.
by is clamped to EgressConfig::max_hold. A clamp is reported
as ProxyEvent::Impairment { kind: HoldClamped { .. } }.
§Resolution
Release timing does not use tokio::time::sleep, which is
bounded below by the ~15.6 ms Windows system tick — as is every
other interruptible wait in std. Releases are driven by a
process-wide release wheel on one dedicated OS thread, measured at
p50 0.11-0.17 ms, p95 0.52-0.57 ms end-to-end on Windows 11
against 11-15 ms for
tokio::time::sleep. The measured lateness of every deferred
release is reported in
crate::instrument::Counters::release_errors; assert on it
rather than assuming the delay was honoured.
Three consequences worth knowing:
- When
MOQTAP_RELEASE_TIMERforces the coarse backend, the floor returns to ~15.6 ms on Windows, and the session says so exactly once withProxyEvent::Impairment { kind: CoarseReleaseTimer { .. } }. A run that could not honour its own delays is never silent about it. Seecrate::instrument::release_timer_backend. - The wheel is on a real clock, not tokio’s. A test using
#[tokio::test(start_paused = true)]and expecting aDelayto complete will wait out the real deadline, not advance virtual time. Do not pause time aroundDelayorHold. - The wheel’s thread is created on the first deferred release in
the process and is never joined (it lives in a
static OnceLock). Leak detectors and thread counters will see one live thread and one leaked allocation after any delaying test. A session that never delays never creates it —crate::instrument::release_timer_startedis the falsifiable form of that claim.
Fields
Hold
Release then when gate is released, at
EgressConfig::max_hold, or at session teardown — whichever comes
first.
Drop(DropMode)
Remove the unit from the wire. See DropMode.
Truncate
Write the first bytes bytes of this unit, then reset the stream.
Positional: everything queued ahead of it is written first, then
the truncated prefix, then RESET_STREAM with code.
code is stated rather than defaulted, exactly as in
Self::ResetStream. A truncation is a simulated publisher
abandonment and the code is the whole of what the scenario is
simulating: 0x0 INTERNAL_ERROR reads as “the proxy did this”, 0x2
DELIVERY_TIMEOUT reads as a relay hit its delivery timeout, and the
two make a subscriber take different paths. There is no default that is
right for both, so the type asks. The same range check as
Self::ResetStream applies:
Refusal::ErrorCodeOutOfRange
above 2^62 - 1, before anything is sent. On drafts 07-10, which define
no stream-reset code vocabulary at all, the reset still executes and
Effect::Truncated reports code_defined: false.
The peer observes at most bytes further bytes, and may observe
none. quinn clears the receive assembler the moment RESET_STREAM is
processed (quinn-proto/src/connection/streams/recv.rs: Nuke buffers
so that future reads fail immediately), so only bytes the peer
application had already read out survive; reset() additionally
discards anything still in the local send buffer. Assert an upper bound
and a prefix, never an exact count.
Refused on control streams on every draft, and on datagrams.
Fields
ResetStream
Reset the destination stream with this application error code.
Refused on control streams on every draft: resetting a control
stream at the transport layer is a session-level PROTOCOL_VIOLATION
in all of drafts 07-19. The documented escalation is
Self::CloseSession.
Codes above the QUIC varint ceiling (2^62 - 1) are refused with
Refusal::ErrorCodeOutOfRange
before anything is sent, rather than silently becoming a FIN.
CloseSession
Close both legs of the session with this code and reason.
code is a session termination code, a different namespace from
a stream reset code: 0x0 NO_ERROR, 0x1 INTERNAL_ERROR, 0x2
UNAUTHORIZED, 0x3 PROTOCOL_VIOLATION. Note that session
INTERNAL_ERROR is 0x1 while stream-reset INTERNAL_ERROR is 0x0.
Honoured at every site that returns an Action, including
Site::StreamEnd on a data
stream and on the control stream. A close is session-scoped, so
no site can be the wrong one for it: unlike
Self::ResetStream, there is no per-stream object it needs and
nothing left to forward that honouring it could corrupt. It is the
documented escalation for the two sites where a stream reset is a
protocol violation.
The first close request wins; later ones are refused with
Refusal::SessionAlreadyClosing.