Skip to content

fix: release upstream subscriptions for idle cached tracks, and stop FIN-ing subgroup streams mid-object - #196

Merged
englishm merged 4 commits into
cloudflare:mainfrom
englishm-cloudflare:me/release-idle-upstream-subscriptions
Jul 30, 2026
Merged

fix: release upstream subscriptions for idle cached tracks, and stop FIN-ing subgroup streams mid-object#196
englishm merged 4 commits into
cloudflare:mainfrom
englishm-cloudflare:me/release-idle-upstream-subscriptions

Conversation

@englishm-cloudflare

Copy link
Copy Markdown
Contributor

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-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-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:

  • 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 §13.4.4 data stream reset codes (DataStreamResetCode) and map failures onto them: CANCELLED for a subscription ending early, MALFORMED_TRACK for an upstream object shorter than declared, SESSION_CLOSED / INTERNAL_ERROR otherwise.

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".

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 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 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 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.

Grace period defaults to 30s, configurable via --cache-idle-timeout; 0 restores the previous behaviour.

The timeout is plumbed through a new Relay::new_with_cache_idle_timeout constructor rather than a RelayConfig field, specifically so that exhaustive struct-literal construction of RelayConfig keeps compiling for embedders. See Compatibility below for the API surface this does break.

3. perf: stream namespace paths into the formatter

Display for TrackNamespace called namespace_path() internally and built a String, so it was no cheaper than to_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. Display now 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 lag

Lease registry lock poisoning means a PrefixInterest can 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. Broadcast Lagged is recovered from with a full resync (correct, but silent). Both are now counted. Also registers moq_relay_cache_idle_evictions_total, which this branch emits but which had no describe_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:

Method Before After
Locals::register_namespace mpsc::Receiver<TrackWriter> mpsc::Receiver<TrackRequest>
Locals::get_or_request_track Option<TrackReader> Option<(TrackReader, Option<TrackInterestGuard>)>
RemoteManager::subscribe Result<Option<TrackReader>> Result<Option<(TrackReader, TrackInterestGuard)>>

Intentional. release-plz picks up breaking API changes at release prep and bumps semver accordingly.

Preserved deliberately: RelayConfig is 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:

  1. Should a relay start writing an object downstream before it has the whole object from upstream? Today it does — it commits to payload_length and then streams chunks. Good for latency, but it commits to a length it cannot yet guarantee.
  2. What should a relay do when the remainder of an already-committed object cannot be obtained? Once the header is written there is no legal way to un-promise those bytes. Resetting the subgroup (what this does) discards objects that were fine. MALFORMED_TRACK is arguably the wrong code — the track isn't malformed, the upstream delivery failed — and INTERNAL_ERROR is 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

englishm added 4 commits July 29, 2026 19: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-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.
@englishm-cloudflare
englishm-cloudflare marked this pull request as ready for review July 30, 2026 16:45
@englishm
englishm merged commit 8a963bc into cloudflare:main Jul 30, 2026
2 checks passed
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.

Relay retains upstream track subscription after the last downstream subscriber leaves

2 participants