Skip to content

fix: release upstream subscriptions for idle cached tracks, and stop FIN-ing subgroup streams mid-object (draft-14) - #198

Merged
englishm merged 5 commits into
cloudflare:draft-ietf-moq-transport-14from
englishm-cloudflare:me/backport-14-idle-upstream-subscriptions
Jul 31, 2026
Merged

fix: release upstream subscriptions for idle cached tracks, and stop FIN-ing subgroup streams mid-object (draft-14)#198
englishm merged 5 commits into
cloudflare:draft-ietf-moq-transport-14from
englishm-cloudflare:me/backport-14-idle-upstream-subscriptions

Conversation

@englishm-cloudflare

Copy link
Copy Markdown
Contributor

What this changes

Backport of #196 to draft-ietf-moq-transport-14.

#191 was reported against the draft-14 deployment, so the fix needs to land on that branch as well as main. main is 112 commits ahead of this base and the relay was substantially reworked in between — ANNOUNCE became PUBLISH_NAMESPACE, Locals grew a pull-through cache, subscribed.rs went from 463 to 1337 lines — so nothing cherry-picked cleanly. This is a re-implementation of the same fixes against draft-14's structure. Where the shape had to change, it is called out below.

1. Reset subgroup streams instead of FIN-ing mid-object

quinn::SendStream::drop calls finish() — a clean FIN at the current offset. subscribed.rs built SubgroupOutput::Stream(Writer::new(send_stream)) and never called finish() explicitly, so every early return from the forwarding loop sent a FIN wherever it happened to stop.

When that offset is inside an object, the receiver has already been promised payload_length bytes by the object header. It sees a subgroup that ended cleanly mid-object and treats the track as malformed.

Two triggers in a relay:

  • Downstream UNSUBSCRIBErecv_unsubscriberemove_subscribeObjectForwarderRecv dropped → the state.lock_mut().ok_or(ServeError::Done)? in serve_subgroup_objects fails after the object header was already encoded → Writer dropped → FIN mid-object.
  • Upstream failing mid-objectSubgroupObjectWriter dropped with remain != 0 sets ServeError::Size, producing the same truncating FIN downstream.

draft-14 §10.4.3 ("Closing Subgroup Streams") permits a FIN only when every object in the subgroup has been delivered to the QUIC stream, and names UNSUBSCRIBE as a case that MUST use RESET_STREAM. The requirement is unchanged from draft-16, so the fix is the same:

  • A SubgroupStream wrapper that resets on drop unless explicitly finished, so the spec-safe outcome is the default and async cancellation is covered without a separate code path.
  • Track owed payload bytes and refuse to FIN while mid-object.
  • Move the liveness/location check ahead of the object header encode, so a cancellation in that window stops on an object boundary rather than after the promise.
  • Add the reset code registry as DataStreamResetCode and map failures onto it.

The regression test was verified to actually catch the bug: reverting only the check reordering makes unsubscribe_mid_subgroup_resets_at_an_object_boundary fail with "no partial object should follow the last complete one" — the same failure it produces on main.

Draft-14 difference — the reset code registry is smaller. draft-14 §13.1.8 defines only four codes: INTERNAL_ERROR (0x0), CANCELLED (0x1), DELIVERY_TIMEOUT (0x2), SESSION_CLOSED (0x3). MALFORMED_TRACK (0x12) does not exist yet, so the case #196 mapped to it — an upstream object that ran short of its declared payload_length — collapses into INTERNAL_ERROR here, with a comment at the mapping recording why. CANCELLED and SESSION_CLOSED map identically to main. This is the least-wrong option available on the wire for draft-14.

2. Release upstream subscriptions for idle cached tracks (#191)

A relay caches tracks it does not publish itself so several downstream subscribers can share one upstream subscription. Nothing counted how many subscribers were actually using a cached reader, so the upstream subscription lived until the upstream session died — after the last subscriber left, the relay kept receiving a track nobody was watching.

New TrackInterest / TrackInterestGuard: a reference count backed by a watch channel that can also await "unwatched for a while". Subscribers hold a guard while being served, so the count is maintained by RAII and a cancelled subscriber cannot leak a reference. Once an entry has been unwatched for the grace period it is evicted and the upstream Subscribe is dropped, sending UNSUBSCRIBE.

Both cache layers are covered: the pull-through cache reached through TracksReader::subscribe, and the cross-relay track cache in RemoteManager.

Three details this depends on, unchanged from #196:

  • Guards hold a strong reference to the counter, not a cache key. An outstanding guard from an evicted entry decrements the counter it came from rather than making a same-named replacement look busy.
  • Guards are created under the same lock the idle check is made under. A subscriber racing eviction is therefore either counted (eviction abandoned) or misses the cache and requests a fresh entry.
  • The entry is cleared before the upstream subscription is dropped, so a subscriber arriving in that window re-subscribes rather than attaching to a reader about to go silent.

Draft-14 difference — TrackInterest lives in moq_transport::serve, not the relay crate. On main the pull-through cache is in Locals inside moq-relay-ietf, so interest tracking naturally went there. On draft-14 the cache is TracksState inside moq-transport, and the second invariant above requires the guard be taken under the same lock as the idle check — which is that crate's lock. Putting the guard in the relay crate would mean taking it after releasing the cache lock, reopening exactly the eviction race the design exists to close. So it goes in moq-transport alongside the state it guards. #180 independently reached the same placement on the same base.

This is what makes the API break land on moq-transport rather than moq-relay-ietf — see Compatibility.

3. Bound the upstream subscribe handshake

Related to the above, and part of the same problem: subscribe_open waits for SUBSCRIBE_OK, which an upstream is under no obligation to ever send. Subscribe::ok() loops on a state notify until the ack arrives or the session closes, so the wait was effectively unbounded. This predates the PR — the previous code called Subscriber::subscribe, which is subscribe_open().await? followed by closed().await, so the same wait was there one frame deeper — but it undercuts the point of the change above, since a never-acked subscribe pins the task and the TrackWriter it carries until the session dies.

Bounded two ways, because they cover different failures:

  • lease.released() — nobody downstream is waiting for this track any more. This is the condition that actually matters and needs no arbitrary constant. remote.rs already raced its handshake against connection cancellation; the local path was the odd one out.
  • --subscribe-timeout (default 10s) — a subscriber is still waiting, but the upstream never answers. Applied symmetrically to both the local pull-through path and the cross-relay path.

Ten seconds is a policy choice rather than a protocol constant, since MoQ does not bound SUBSCRIBE_OK. A downstream subscriber is blocked for the whole window, and upstream is normally a nearby relay or origin where a control round trip is well under a second.

Either mechanism drops the in-flight future, which drops the Subscribe, whose Drop sends UNSUBSCRIBE — so giving up mid-handshake leaves nothing dangling upstream.

4. Stream namespace paths into the formatter

Display for TrackNamespace called to_utf8_path() internally and built a String, so it was no cheaper than calling to_utf8_path() directly — and the %namespace.to_utf8_path() pattern at tracing call sites allocated on every call, including at log levels that were not enabled. Display now streams into the formatter and to_utf8_path() delegates to it, so there is one implementation of the path format. Tracing call sites become %namespace, which is both lazy and allocation-free. Callers that genuinely want an owned String (the moq-pub catalog field, the file coordinator, coordinator tests) keep using to_utf8_path().

Not backported

The lock-poisoning and broadcast-lag metrics from #196 do not apply: the lease registry and broadcast resync paths they instrument were added after draft-14 and have no equivalent here. The one piece that does apply — the missing describe_counter! for moq_relay_cache_idle_evictions_total, which this branch emits — is folded into part 2 so the counter ships with Prometheus HELP text.

Configuration

Flag Default Meaning
--cache-idle-timeout 30 (seconds) How long a cached track with no downstream subscribers is retained before its upstream subscription is released. 0 disables eviction, restoring the previous behaviour.
--subscribe-timeout 10 (seconds) How long to wait for an upstream to acknowledge a SUBSCRIBE. Must be at least 1 — an unbounded wait on a peer is what the timeout exists to prevent, so there is deliberately no "disabled" setting. Rejected by clap, and again by Relay::new_with_tuning for embedders that bypass the CLI.

New metrics: moq_relay_cache_idle_evictions_total and moq_relay_subscribe_timeouts_total, both labelled by source (local, remote) so the two cache layers can be told apart.

Testing

cargo fmt --check, cargo clippy --no-deps --all-targets, cargo test (180 passing), cargo machete — all clean.

New coverage: subgroup FIN-vs-RESET termination and reset-code mapping; the draft-14 §13.1.8 code values as a registry test; interest counting, grace-period restart and generation identity; idle eviction, warm-cache reuse within the grace period, cancelled-requester safety, zero-timeout opt-out, and stale-guard isolation on both the local and remote paths; Display/to_utf8_path/Debug equivalence including the non-UTF-8 lossy case; the documented tuning defaults, that each timeout builder sets only its own knob (both are Duration, so crossing them would compile silently), that a zero subscribe timeout is rejected, and that a zero cache idle timeout still is not.

Not covered by a new test: timeout expiry itself is tokio::time::timeout over an existing call, and the drop-sends-UNSUBSCRIBE behaviour it relies on is Subscribe's existing tested Drop. A call-site test would need a full session mock for no additional assurance.

Compatibility

This is a source-breaking change to moq-transport — note that this differs from #196, which broke moq-relay-ietf instead, for the reason given in part 2 above.

Method Before After
TracksReader::subscribe Option<TrackReader> Option<(TrackReader, Option<TrackInterestGuard>)>
TracksRequest::next Option<TrackWriter> Option<TrackRequest>

TrackRequest implements Deref<Target = TrackWriter>, so .name, .namespace and .info still work unchanged on the yielded value; only code that needs to move the writer has to reach for .writer.

Guard-less variants of the two methods above were not kept as an additive migration path. The design's correctness depends on the interest guard being created under the same lock as the idle check, so a method that returns a cached reader without a guard hands back an entry that registers no interest — it looks idle immediately and can be evicted while it is still being served. That is the bug class this PR fixes, so a compile error at each call site is preferable to an API that silently reintroduces it.

Preserved deliberately: Tracks struct literals, Tracks::new, Tracks::produce, TracksWriter::create / remove, get_track_reader, and TracksState: Default are all unchanged, and the idle timeout is opt-in through Tracks::produce_with_cache_idle_timeout.

moq-relay-ietf is additive. RelayConfig is untouched, so embedders constructing it as an exhaustive struct literal are unaffected — that is why tuning is passed separately rather than as config fields. Relay::new(config) keeps working on defaults. New public surface: RelayTuning (#[non_exhaustive], with Default and builders), Relay::new_with_tuning, DEFAULT_SUBSCRIBE_TIMEOUT, and RemoteManager::with_cache_idle_timeout / with_subscribe_timeout.

Relationship to #180

#180 is an independent fix for #191 on this same branch, and it converged on the same TrackRequest { writer, lease } shape and the same moq_transport::serve placement — useful corroboration that the placement is forced by draft-14's structure rather than a matter of taste. Thanks @thexeos.

This PR additionally covers the cross-relay RemoteManager cache, the subgroup FIN/RESET issue, and the handshake bounding described above, and keeps the backport aligned with what landed on main in #196.

englishm added 5 commits July 30, 2026 13:35
A subgroup data stream was terminated by dropping its `Writer`, and
`quinn::SendStream::drop` implicitly calls `finish()`. Every early return
from the forwarding loop therefore sent a clean FIN at whatever byte
offset we happened to stop at.

When that offset lands inside an object the receiver has already been
promised `payload_length` bytes by the object header, so it sees a
subgroup that ended cleanly in the middle of an object and treats the
track as malformed. Draft-14 section 10.4.3 permits a FIN only when every
object in the subgroup was delivered, and explicitly lists early
termination due to UNSUBSCRIBE as a case that MUST use RESET_STREAM.

Two paths hit this in a relay:

  - a downstream UNSUBSCRIBE closes the subscribed state, which was
    checked only *after* the object header had been encoded
  - an upstream track failing mid-object surfaces as `ServeError::Size`
    once the payload runs short of the declared length

Wrap the writer in a `SubgroupStream` that resets on drop unless it was
explicitly finished, so the spec-safe outcome is the default one and
async cancellation is covered too. Track how many payload bytes are still
owed and refuse to FIN while mid-object. Move the subscription liveness
and location checks ahead of the object header encode so a cancellation
in that window stops at an object boundary rather than after the promise.

Split the body of `serve_subgroup` into `serve_subgroup_objects` writing
to a `SubgroupOutput`, so the termination decision lives in exactly one
place and tests can assert FIN-vs-RESET against a buffer sink without a
real QUIC connection.

`Writer` gains explicit `finish()` and `reset()`; previously it had
neither and relied entirely on drop behaviour.

Add the draft-14 section 13.1.8 data stream reset codes and map failures
onto them: CANCELLED for a subscription ending early, SESSION_CLOSED for
a closed session, INTERNAL_ERROR otherwise. Draft-16 added
MALFORMED_TRACK (0x12) for an upstream object shorter than its declared
length, but draft-14 has no equivalent code, so that case reports
INTERNAL_ERROR here.

The regression test was verified to catch the bug: reverting only the
check reordering makes `unsubscribe_mid_subgroup_resets_at_an_object_boundary`
fail with "no partial object should follow the last complete one".
A relay caches tracks it does not publish itself so that several
downstream subscribers can share one upstream subscription. Nothing
counted how many subscribers were actually using a cached reader, so the
upstream subscription lived until the upstream session died. After the
last subscriber left, the relay kept receiving — and paying for — a track
nobody was watching.

Reported against the draft-14 deployment: the upstream subscription stayed
active for more than 80 seconds after the last subscriber left, with no
UNSUBSCRIBE ever emitted, so a publisher cannot use the relay's upstream
subscription lifecycle to tell when downstream demand has reached zero.

Add `TrackInterest`, a reference count backed by a `watch` channel that
can also await "unwatched for a while". Downstream subscribers hold a
`TrackInterestGuard` for as long as they are being served, so the count is
maintained by RAII and a cancelled subscriber cannot leak a reference.
Once a cached entry has been unwatched for the grace period it is evicted
and the upstream `Subscribe` is dropped, which sends UNSUBSCRIBE.

Both cache layers are covered: the pull-through cache behind
`TracksReader::subscribe`, and the cross-relay track cache in `Remote`.

Three details this depends on:

  - Guards hold a strong reference to the counter, not a cache key. An
    outstanding guard from an evicted entry decrements the counter it was
    taken from instead of making a same-named replacement look busy.
  - Guards are created while holding the same lock that the idle check is
    made under. A subscriber racing eviction is therefore either counted
    (and eviction is abandoned) or misses the cache and requests a fresh
    entry.
  - The cache entry is cleared before the upstream subscription is
    dropped, so a subscriber arriving in that window re-subscribes rather
    than attaching to a reader that is about to go silent.

`Consumer` now holds the upstream subscription explicitly via
`subscribe_open` and selects on `Subscribe::closed()` against
`CacheLease::released()`, rather than calling `subscribe()` and awaiting
only upstream events. `Subscribe::drop` is the only place the transport
crate emits UNSUBSCRIBE, so owning the handle is what makes releasing it
possible.

The grace period keeps the common case cheap: a reconnecting subscriber,
or a player switching renditions, reuses the warm entry instead of paying
a fresh upstream SUBSCRIBE round trip. It defaults to 30s and is
configurable via `--cache-idle-timeout`; zero restores the previous
behaviour of holding upstream subscriptions for the session's lifetime.

Plumbed through a new `Relay::new_with_cache_idle_timeout` constructor and
`Consumer::with_cache_idle_timeout` / `RemoteManager::with_cache_idle_timeout`
builders, so that existing struct-literal construction of `RelayConfig`
and existing `Consumer::new` / `RemoteManager::new` callers keep compiling.

Interest tracking lives in `moq_transport::serve` rather than the relay
crate because on this branch the pull-through cache is `TracksState`
inside `moq-transport`; the correctness argument depends on the guard
being taken under the same lock as the idle check, so it has to be
reachable from `TracksReader::subscribe`.

This is source-breaking for `moq-transport`: `TracksReader::subscribe`
returns the interest guard alongside the reader, and `TracksRequest::next`
yields a `TrackRequest` carrying the writer and its cache lease.
`TrackRequest` derefs to `TrackWriter` so field access is unaffected; only
moves of the writer need `.writer`. A guard-less `subscribe` was
deliberately not kept as a migration path, because a cached reader handed
out without a guard registers no interest, looks idle immediately, and can
be evicted while it is still being served — the bug class this fixes.

Also registers `moq_relay_cache_idle_evictions_total`, which this change
emits and which would otherwise ship with no Prometheus HELP text.
`TrackNamespace`'s `Display` impl called `to_utf8_path()`, which builds a
`String` by pushing each field. Because `Display` is what `tracing`'s `%`
sigil uses, every namespace field on a hot relay path allocated — and the
call sites made it worse by calling `to_utf8_path()` themselves *before*
the macro, so the `String` was built unconditionally even when the event
was filtered out.

Invert the relationship: `Display` now streams `/`-separated fields
straight into the formatter, and `to_utf8_path()` delegates to
`to_string()` for the callers that genuinely need an owned `String`.
`String::from_utf8_lossy` borrows for valid UTF-8, so only fields that
are not valid UTF-8 allocate a replacement string.

Then drop the redundant `.to_utf8_path()` from tracing call sites so the
path is rendered lazily, only when a subscriber records the event. The
remaining `to_utf8_path()` callers (the moq-pub catalog field, the file
coordinator, coordinator tests) really do want a `String`.

Add tests pinning `Display`/`to_utf8_path`/`Debug` to the same output,
including the non-UTF-8 lossy case, so the delegation cannot drift.
`subscribe_open` waits for SUBSCRIBE_OK, which an upstream is under no
obligation to ever send. The `lease.released()` arm only became live
after that call returned, so a never-acked subscribe pinned the task —
and the `TrackWriter` it carries — until the session died. That is the
resource leak this path exists to avoid, just relocated into the
handshake window.

Race the handshake against the lease as well. Dropping the in-flight
future drops the `Subscribe`, whose `Drop` sends UNSUBSCRIBE, so
cancelling mid-handshake is still clean. `remote.rs` already races its
handshake against connection cancellation for the same reason; this
brings the local path back in line.

Both signals were already covered by tests — `CacheLease::released` in
`tracks.rs` and UNSUBSCRIBE-on-drop in `Subscribe` — so no new coverage
is added here; the change is wiring an existing signal into one more
place.

Raised by AI review on !32 as an RFC-013 "every external call MUST have
a timeout" violation. This is not a wall-clock timeout: MoQ specifies no
bound for SUBSCRIBE_OK, so rather than invent a constant this makes the
wait cancellable by the condition that actually matters — nobody
downstream is waiting for the track any more.
Racing the handshake against downstream interest covered the case where
nobody is waiting for the track any more, but not the case where a
subscriber *is* still waiting and the upstream simply never answers. RFC-013
requires every external call to have a timeout, and `subscribe_open` — which
waits for SUBSCRIBE_OK — had none on either relay path.

Add `--subscribe-timeout`, defaulting to 10s, applied symmetrically to the
local pull-through path in `consumer.rs` and the cross-relay path in
`remote.rs`. On expiry the in-flight future is dropped, which drops the
`Subscribe` and sends UNSUBSCRIBE, so giving up leaves nothing dangling
upstream. Expiry is counted by `moq_relay_subscribe_timeouts_total`, labelled
by source so the two paths can be told apart.

Ten seconds is a policy choice, not a protocol constant: MoQ does not bound
SUBSCRIBE_OK. A downstream subscriber is blocked for the whole window and
upstream is normally a nearby relay or origin where a control round trip is
well under a second, so this is generous without leaving a subscriber
waiting long past the point it has given up.

There is deliberately no zero-disables setting, unlike `--cache-idle-timeout`
where zero meaningfully means "never evict". An unbounded wait on a peer is
the thing this timeout exists to prevent, so zero is rejected by clap and
again by `Relay::new_with_tuning` for embedders that bypass the CLI.

Config plumbing changes shape to accommodate the second knob:
`Relay::new_with_cache_idle_timeout` is replaced by `new_with_tuning`, taking
a `#[non_exhaustive]` `RelayTuning` with `Default` and builder methods. The
`new_with_*` naming did not survive a second knob, and a struct means further
knobs can be added without another constructor or a breaking change.
`RelayConfig` remains untouched, so embedders building it as an exhaustive
struct literal are still unaffected. `RemoteManager` and `Remote` now carry
`RelayTuning` rather than loose `Duration` fields, which also keeps
`Remote::connect` under clippy's argument-count limit.

Tests cover the parts that can regress silently: the documented defaults, that
each builder sets only its own knob (both are `Duration`, so crossing them
would compile), that a zero subscribe timeout is rejected, and that a zero
cache idle timeout still is not. The expiry path itself is `tokio::time::timeout`
over an existing call, with drop behaviour already covered by `Subscribe`.

Raised by AI review on !32.
@englishm-cloudflare
englishm-cloudflare marked this pull request as ready for review July 31, 2026 02:07
@englishm
englishm merged commit c8e176b into cloudflare:draft-ietf-moq-transport-14 Jul 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants