fix: release upstream subscriptions for idle cached tracks, and stop FIN-ing subgroup streams mid-object (draft-14) - #198
Merged
Conversation
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
marked this pull request as ready for review
July 31, 2026 02:07
nnazo
approved these changes
Jul 31, 2026
6 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.mainis 112 commits ahead of this base and the relay was substantially reworked in between — ANNOUNCE became PUBLISH_NAMESPACE,Localsgrew a pull-through cache,subscribed.rswent 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::dropcallsfinish()— a clean FIN at the current offset.subscribed.rsbuiltSubgroupOutput::Stream(Writer::new(send_stream))and never calledfinish()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_lengthbytes by the object header. It sees a subgroup that ended cleanly mid-object and treats the track as malformed.Two triggers in a relay:
recv_unsubscribe→remove_subscribe→ObjectForwarderRecvdropped → thestate.lock_mut().ok_or(ServeError::Done)?inserve_subgroup_objectsfails after the object header was already encoded →Writerdropped → FIN mid-object.SubgroupObjectWriterdropped withremain != 0setsServeError::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:SubgroupStreamwrapper 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.DataStreamResetCodeand 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_boundaryfail with "no partial object should follow the last complete one" — the same failure it produces onmain.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 declaredpayload_length— collapses intoINTERNAL_ERRORhere, with a comment at the mapping recording why.CANCELLEDandSESSION_CLOSEDmap identically tomain. 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 awatchchannel 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 upstreamSubscribeis dropped, sending UNSUBSCRIBE.Both cache layers are covered: the pull-through cache reached through
TracksReader::subscribe, and the cross-relay track cache inRemoteManager.Three details this depends on, unchanged from #196:
Draft-14 difference —
TrackInterestlives inmoq_transport::serve, not the relay crate. Onmainthe pull-through cache is inLocalsinsidemoq-relay-ietf, so interest tracking naturally went there. On draft-14 the cache isTracksStateinsidemoq-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 inmoq-transportalongside the state it guards. #180 independently reached the same placement on the same base.This is what makes the API break land on
moq-transportrather thanmoq-relay-ietf— see Compatibility.3. Bound the upstream subscribe handshake
Related to the above, and part of the same problem:
subscribe_openwaits 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 calledSubscriber::subscribe, which issubscribe_open().await?followed byclosed().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 theTrackWriterit 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.rsalready 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, whoseDropsends UNSUBSCRIBE — so giving up mid-handshake leaves nothing dangling upstream.4. Stream namespace paths into the formatter
Display for TrackNamespacecalledto_utf8_path()internally and built aString, so it was no cheaper than callingto_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.Displaynow streams into the formatter andto_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 ownedString(the moq-pub catalog field, the file coordinator, coordinator tests) keep usingto_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!formoq_relay_cache_idle_evictions_total, which this branch emits — is folded into part 2 so the counter ships with Prometheus HELP text.Configuration
--cache-idle-timeout30(seconds)0disables eviction, restoring the previous behaviour.--subscribe-timeout10(seconds)Relay::new_with_tuningfor embedders that bypass the CLI.New metrics:
moq_relay_cache_idle_evictions_totalandmoq_relay_subscribe_timeouts_total, both labelled bysource(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/Debugequivalence including the non-UTF-8 lossy case; the documented tuning defaults, that each timeout builder sets only its own knob (both areDuration, 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::timeoutover an existing call, and the drop-sends-UNSUBSCRIBE behaviour it relies on isSubscribe's existing testedDrop. 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 brokemoq-relay-ietfinstead, for the reason given in part 2 above.TracksReader::subscribeOption<TrackReader>Option<(TrackReader, Option<TrackInterestGuard>)>TracksRequest::nextOption<TrackWriter>Option<TrackRequest>TrackRequestimplementsDeref<Target = TrackWriter>, so.name,.namespaceand.infostill 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:
Tracksstruct literals,Tracks::new,Tracks::produce,TracksWriter::create/remove,get_track_reader, andTracksState: Defaultare all unchanged, and the idle timeout is opt-in throughTracks::produce_with_cache_idle_timeout.moq-relay-ietfis additive.RelayConfigis 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], withDefaultand builders),Relay::new_with_tuning,DEFAULT_SUBSCRIBE_TIMEOUT, andRemoteManager::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 samemoq_transport::serveplacement — 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
RemoteManagercache, the subgroup FIN/RESET issue, and the handshake bounding described above, and keeps the backport aligned with what landed onmainin #196.