Skip to content

fix: bound the upstream subscribe handshake with a wall-clock timeout - #204

Open
thexeos wants to merge 1 commit into
cloudflare:mainfrom
thexeos:fix/upstream-subscribe-timeout
Open

fix: bound the upstream subscribe handshake with a wall-clock timeout#204
thexeos wants to merge 1 commit into
cloudflare:mainfrom
thexeos:fix/upstream-subscribe-timeout

Conversation

@thexeos

@thexeos thexeos commented Aug 13, 2026

Copy link
Copy Markdown

subscribe_open waits for an upstream to answer a SUBSCRIBE, which a peer is under no obligation to ever do, and neither relay path bounded that wait — a never-answered subscribe pinned the task, its TrackWriter, and every downstream subscriber waiting on it until the session died. This bounds the handshake two ways on both the local pull-through path and the cross-relay path: it races lease.released() against the handshake itself (covering "nobody downstream is waiting any more"), and adds --subscribe-timeout, defaulting to 10s, for the case where a subscriber is still waiting. Either way 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.

This forward-ports the same protection from the draft-ietf-moq-transport-14 branch, adapted to this branch's UpstreamReady plumbing. Note that config plumbing changes shape to carry a second knob, with new_with_cache_idle_timeout becoming new_with_tuning(config, RelayTuning) — happy to keep a deprecated alias instead if you would rather not break embedders.

`subscribe_open` waits for the upstream to answer a SUBSCRIBE, which a peer
is under no obligation to ever do, and neither relay path bounded that wait.
On the local pull-through path a never-answered subscribe pinned the task,
the `TrackWriter` it carries, and every downstream subscriber blocked on
`UpstreamReady::established()`, until the session died; the cross-relay path
in `remote.rs` raced only connection cancellation. The `lease.released()`
arm existed but only became live after the handshake returned, so the window
it was meant to cover was exactly the window it did not cover.

Bound the handshake two ways on both paths. `lease.released()` now races the
handshake itself, covering "nobody downstream is waiting for this track any
more" without inventing a constant. `--subscribe-timeout` (default 10s)
covers the rest: a subscriber that is still waiting cannot wait forever on a
peer that never answers. Either way the in-flight future is dropped, which
drops the `Subscribe` and sends UNSUBSCRIBE, so giving up leaves nothing
dangling upstream; the local path also fails the downstream request with
REQUEST_ERROR (Timeout) instead of stranding it. Expiry is counted by
`moq_relay_subscribe_timeouts_total`, labelled by source.

Unlike `--cache-idle-timeout`, where zero meaningfully means "never evict",
there is deliberately no zero-disables setting: 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`. Config plumbing changes shape to carry a
second knob: `new_with_cache_idle_timeout` / `build_with_cache_idle_timeout`
become `new_with_tuning` / `build_with_tuning`, taking a `#[non_exhaustive]`
`RelayTuning` with `Default` and builder methods, so further knobs need no
new constructor. `RelayConfig` is untouched, so embedders building it as an
exhaustive struct literal are unaffected.

This forward-ports the same protection from the
`draft-ietf-moq-transport-14` maintenance branch, adapted to this branch's
`UpstreamReady` plumbing and its `Locals`-side eviction accounting.
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.

1 participant