Files
netris-nestri/docs/media-transport.md
T
0811f57f1a feat: media bitrate control, HDR (#346)
Fixes: #335 

Still a work-in-progress.

---------

Co-authored-by: DatCaptainHorse <DatCaptainHorse@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Wanjohi <elviswanjohi47@gmail.com>
2026-09-25 12:13:34 +03:00

209 lines
11 KiB
Markdown

# The media transport, as it actually behaves
Media rides one QUIC connection: video delta frames and audio as datagrams,
keyframes as short-lived unidirectional streams, cursor and stats and input as
streams of their own. Several things about that arrangement are not what they
look like, and we designed against the wrong model twice before reading the
source.
This file records what the transport *does*, with somewhere to check each
claim, and what follows from it. It is deliberately about behaviour rather than
about our code, because our code changes and this does not — except when the
dependency does, which is why the version is named.
Read against **iroh 1.1, which uses `noq` and `noq-proto` 1.3 — a quinn fork,
not quinn itself.** Paths below are inside `noq-proto` unless stated. Reading
upstream quinn instead is a mistake worth naming, because we made it: the
behaviour turned out to agree on every point here, but "turned out to" is
precisely the standard this file exists to replace. If the iroh or noq version
moves, re-read before trusting any of this.
## The rule
**A path is the unit of congestion, not a stream and not a connection.**
Everything else here follows from that one sentence, and almost every wrong
assumption we made came from forgetting some part of it.
## Congestion is per path, and shared by everything on that path
Each path carries its own congestion controller and its own pacer
(`connection/paths.rs`, `PathData::congestion` and `PathData::pacing_delay`),
and `poll_transmit` consults both for the path it is about to send on
(`connection/mod.rs`, in the transmit blocking checks). Within a path, every
stream and every datagram shares them, whatever the packet ends up carrying.
The "per path" part is not pedantry here: a connection commonly holds a relay
path and a direct path at once, they have different windows and different
round-trip times, and only the *selected* one describes where media is
currently going. Reading connection-level aggregates instead will average two
unrelated network conditions together.
So moving keyframes onto their own streams — which we did, and which was right
— changed the *delivery guarantee* and nothing else. A lost fragment no longer
destroys a keyframe, because QUIC retransmits stream data. But that keyframe
still competes for the same congestion window as everything else on the path,
and still queues in the same scheduler. Stream-versus-datagram is a reliability
distinction, not an isolation one.
The corollary is worth stating because it is tempting and wrong: **opening a
second connection does not create bandwidth.** At a 3 Mbps bottleneck a 150 KB
keyframe occupies about 400 ms of link time however it is carried. A second
connection turns strict queueing into competition between two flows over the
same bottleneck, which is sometimes what you want — but it is a change in
*fairness*, not in capacity, and it costs a second NAT traversal, a second
handshake and a second set of paths to manage.
## Datagrams are written before stream data
`populate_packet` fills each packet in a fixed order, and DATAGRAM frames come
before STREAM frames. Whatever datagrams are queued take their space first;
stream data gets the remainder.
Two consequences, and they point in opposite directions:
**Delta frames cannot be starved by keyframes.** A large keyframe simply
stretches out over many packets while deltas keep flowing beside it. This is
the behaviour we nearly built by hand before checking.
**Keyframes can be starved by delta frames.** On a saturated link a steady
stream of datagrams fills every packet, and the keyframe on its stream gets
leftovers. If the client is waiting on that keyframe to resynchronise, the
frames starving it are the very frames it cannot decode without it. That is a
feedback loop with no bottom, and it is what produced "26 keyframe fallbacks,
19 IDR requests" in the field: the recovery frame could not get out past the
frames that needed it to arrive first.
The fix is not to reorder the transport. It is to stop sending data that
depends on data the receiver does not have — see `dgram::ResyncGate`.
## The send path has no backpressure signal
`send_datagram` calls `datagrams().send(data, true)` (`noq/src/connection.rs`),
and that `true` is a `drop` flag: the queue makes room by **evicting the oldest
queued datagrams** via `make_space_for` and then returns `Ok`
(`connection/datagrams.rs`). It errors only for `TooLarge`, `UnsupportedByPeer`,
`Disabled`, and `ConnectionLost` — that is, for a datagram that was malformed
or a connection that is already gone, never for one the path could not carry.
(The same function with `drop: false` returns `Blocked` instead and is what
`send_datagram_wait` uses. That would give backpressure, at the cost of
prioritising old frames over current ones, which for live media is the wrong
trade.)
A sender overrunning the path is therefore told nothing at all. Any counter
fed by that return value cannot be non-zero however badly things are going,
which is exactly how a field report came to contain "encoder perfectly healthy,
zero frames dropped" beside a client receiving almost nothing. `send_datagram`
is still the right call for live media — waiting for buffer space prioritises
old frames over current ones — but its silence has to be designed around
rather than read as good news.
What *can* be read: `datagram_send_buffer_space()` for remaining room, and the
selected path's `stats()` for `cwnd`, `rtt` and `lost_packets`. Note "selected":
a connection commonly holds a relay path and a direct path at once, and only
the selected one describes where media is going.
## quinn already paces
`poll_transmit` blocks on a token-bucket pacer (`connection/pacing.rs`, reached
through `PathData::pacing_delay`) sized from that path's congestion window and
round-trip time, before every packet. Handing 150 fragments over in a tight
loop does *not* put 150 packets on the wire at once.
Application-level pacing on top of this is redundant, and worse than
redundant: it would spread transmission according to our guess at the available
rate, while the pacer underneath is using the measured window and RTT. If the
application is producing faster than the pacer drains, the answer is to produce
less — which is the bitrate controller's job — not to add a second pacer.
## Loss is a lagging indicator; the queue is the leading one
A path that is overrun does not necessarily drop anything. It can simply be
slower than the offer, in which case the pacer above meters packets out at the
rate it measures and the difference accumulates in the datagram send buffer.
Nothing is lost. Everything is late.
This was measured over a 1000-mile link: every frame arrived, every frame
completed, zero QUIC loss, zero congestion events, and a **flat** 180 ms round
trip for an entire session — while the picture ran up to eight seconds behind.
A flat RTT is the tell that the queue is *ours* and not the network's; a
standing queue in the path would have shown up as rising round-trip time.
The trap is that every health signal reads clean. Loss only appears once the
buffer finally overflows, long after the session became unplayable, and by then
the queue is deep enough that the only way out is the floor. A controller
driven by loss alone therefore climbs to its ceiling against a path it is
already overrunning, collapses when the buffer spills, drains, and climbs
again — a sawtooth with a period of tens of seconds, in which the stream is
never once at a rate the path can carry.
`DGRAM_BUFFER_BYTES` is 4 MiB. That was chosen as headroom for a keyframe
burst, which is a real requirement and still correct. But a buffer sized in
bytes is a queue whose *duration* depends on the drain rate: at a couple of
megabits, 4 MiB is several seconds of video. Sizing for one dimension silently
set a bound in the other.
The signal that does work was available the whole time and simply never read:
```
backlog_bytes = DGRAM_BUFFER_BYTES - conn.datagram_send_buffer_space()
backlog_ms = backlog_bytes * 8 / drain_kbps
```
Divide by the rate the queue actually drains at — what is getting through — and
not by what is being asked for, because the latter is the number that is too
high whenever this matters.
## What follows
These are the design rules we arrived at, and the reasoning is above rather
than in the code that implements them.
**Do not send what the receiver cannot use.** Frames that depend on a keyframe
the receiver is still waiting for are not merely wasted bandwidth; on this
transport they actively delay the keyframe.
**Do not leave a hole for something you chose not to send.** A receiver counts
a missing sequence number as loss, and that count feeds our own congestion
response. A sender that skips a frame *and* burns its sequence number makes the
controller lower the bitrate in response to the sender's own decision. Reusing
the number is safe precisely because nothing went out under it.
**Bound every suppression.** Withholding is correct only while the assumption
behind it holds. If the keyframe never comes, withholding forever converts a
recoverable freeze into a permanent black screen, so the suppression expires
and says that it did.
**Measure the queue, not just the casualties.** Loss says a path was overrun
some time ago. A standing send queue says it is being overrun right now, while
the frames are still only late. On a transport that drops the oldest datagram
rather than refusing the newest, the queue is the only signal that arrives in
time to act on — and climbing is what digs it, so climbing needs the queue to
be empty and not merely the loss to be low.
**Trust the receiver over the sender.** The sender's view of this path was
measured reporting 182 ms RTT with zero packet loss while the client received
almost nothing. Some of that gap is structural, per the section above. The far
end's report of what actually arrived is the only measurement that cannot be
quietly wrong in our favour.
## The method
Every item here was found by reading the dependency's source, and every one of
them contradicted something we believed. The first draft of this very file
cited the wrong crate — upstream quinn rather than the fork iroh actually
builds against — which is a good illustration of how little intuition is worth
here even when the conclusions survive. The same thing happened one layer
down, in Vulkan Video: rate control turned out to be session state rather than
per-frame state, `gopFrameCount` of zero means "implementation chooses" rather
than "infinite" (`UINT32_MAX` means infinite), and a hardcoded QP ceiling made
low bitrate targets silently unreachable. None of that was in any comment we
had written, and two of those we had confidently asserted the opposite of.
So: **when behaviour depends on a dependency's internals, read them, and write
down what you found with a path to check it against.** Not the documentation,
which is frequently about intent; not the interface, which is frequently about
what you may call rather than what will happen. A claim in this file that
nobody can verify in an afternoon is a claim that will be wrong within a year
and believed anyway.