fix: release upstream subscriptions for idle cached tracks, and stop FIN-ing subgroup streams mid-object - #196
Merged
englishm merged 4 commits intoJul 30, 2026
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-16 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 forwarder 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.
Add the draft-16 section 13.4.4 data stream reset codes and map failures
onto them: CANCELLED for a subscription ending early, MALFORMED_TRACK for
an upstream object shorter than declared, SESSION_CLOSED and
INTERNAL_ERROR otherwise.
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. Closes cloudflare#191. 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 local PUBLISH_NAMESPACE pull-through cache in `Locals`, and the cross-relay track cache in `RemoteManager`. 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. 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 rather than a `RelayConfig` field, so that existing struct-literal construction of `RelayConfig` by embedders keeps compiling.
`Display for TrackNamespace` called `namespace_path()` internally, which built a `String`. That made the `Display` impl no cheaper than `to_utf8_path()`, so the `%namespace.to_utf8_path()` pattern used across tracing call sites allocated on every call — including on hot relay paths and at log levels that were not enabled. Replace it with `write_namespace_path()`, which streams into the formatter. `String::from_utf8_lossy` borrows for valid UTF-8, so only genuinely invalid fields allocate. `to_utf8_path()` now delegates to `Display` so there is one implementation of the path format. Drop the explicit `.to_utf8_path()` from tracing call sites: `%namespace` is both lazy (rendered only if the subscriber records the event) and allocation-free. Add tests pinning `Display`, `to_utf8_path` and `Debug` to the same output, including the non-UTF-8 lossy path.
Three conditions were only observable in logs, or not at all. Lease registry lock poisoning: a panic while holding the lock means a `PrefixInterest` can never be retired, so the upstream namespace pull it owns leaks for the lifetime of the process. The visible symptom is upstream subscriptions that never go away, which is hard to trace back to a single log line. Count it on both the release (Drop) and acquire paths. Broadcast channel lag: the namespace and track change receivers recover from `Lagged` with a full resync, which reconstructs the state the skipped events would have produced. That is correct, but silent — sustained churn outgrowing the channel capacity should be visible before it shows up as latency. Count the skipped events. Also register `moq_relay_cache_idle_evictions_total`, which was being emitted without a `describe_counter!` entry and so shipped with no Prometheus HELP text, and document all three in the metrics table.
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
Two independent bugs found while working on #191, plus cleanup.
Fixes #191.
1.
fix: reset subgroup streams instead of FIN-ing mid-objectquinn::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-16 §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.
The fix:
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.DataStreamResetCode) and map failures onto them:CANCELLEDfor a subscription ending early,MALFORMED_TRACKfor an upstream object shorter than declared,SESSION_CLOSED/INTERNAL_ERRORotherwise.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".2.
fix: 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 local PUBLISH_NAMESPACE pull-through cache in
Locals, and the cross-relay track cache inRemoteManager.Three details this depends on:
Grace period defaults to 30s, configurable via
--cache-idle-timeout;0restores the previous behaviour.The timeout is plumbed through a new
Relay::new_with_cache_idle_timeoutconstructor rather than aRelayConfigfield, specifically so that exhaustive struct-literal construction ofRelayConfigkeeps compiling for embedders. See Compatibility below for the API surface this does break.3.
perf: stream namespace paths into the formatterDisplay for TrackNamespacecallednamespace_path()internally and built aString, so it was no cheaper thanto_utf8_path()— 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;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.4.
feat: add metrics for lock poisoning and broadcast lagLease registry lock poisoning means a
PrefixInterestcan never be retired, so the upstream namespace pull it owns leaks for the process lifetime — the visible symptom is upstream subscriptions that never go away, which is hard to trace back to one log line. BroadcastLaggedis recovered from with a full resync (correct, but silent). Both are now counted. Also registersmoq_relay_cache_idle_evictions_total, which this branch emits but which had nodescribe_counter!entry and so shipped with no Prometheus HELP text.Testing
cargo fmt --check,cargo clippy --no-deps,cargo test(386 passing),cargo machete— all clean.New coverage: subgroup FIN-vs-RESET termination and reset-code mapping; 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.
Compatibility
This is a source-breaking change to
moq-relay-ietf. Three exported methods change return type so that a downstream-interest guard travels with the reader:Locals::register_namespacempsc::Receiver<TrackWriter>mpsc::Receiver<TrackRequest>Locals::get_or_request_trackOption<TrackReader>Option<(TrackReader, Option<TrackInterestGuard>)>RemoteManager::subscribeResult<Option<TrackReader>>Result<Option<(TrackReader, TrackInterestGuard)>>Intentional. release-plz picks up breaking API changes at release prep and bumps semver accordingly.
Preserved deliberately:
RelayConfigis unchanged, so embedders constructing it as an exhaustive struct literal are unaffected. That is why the idle timeout went through a new constructor instead of a config field, and it was verified by compiling a downstream embedder against this branch.Guard-less variants of the three 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.
Open questions (not addressed here)
Worth flagging for discussion rather than blocking this PR:
payload_lengthand then streams chunks. Good for latency, but it commits to a length it cannot yet guarantee.MALFORMED_TRACKis arguably the wrong code — the track isn't malformed, the upstream delivery failed — andINTERNAL_ERRORis no better. There may be a gap in the §13.4.4 registry for "relay could not obtain the rest of an object it had already committed to", which is a normal relay condition rather than a fault in either the track or the relay.Notes
draft-ietf-moq-transport-14-based relay: propagate downstream interest loss with warm-cache linger + upstream UNSUBSCRIBE #180 overlaps with part 2 and independently converged onTrackRequest { writer, lease }. It targets an obsolete base with a failing build; differences to reconcile if it is revived are that it places interest tracking inmoq_transport::serverather than the relay crate, uses a 3s linger, and does not appear to coverRemoteManager.