Skip to content

moq-net/moq-ffi: no blind subscribe, so a relay that does not announce (Cloudflare draft-16) is unreachable from every non-JS binding #3532

Description

@jmvillagra

Summary

moq-net (and therefore moq-ffi and the Kotlin/Swift/Python bindings) can only reach a broadcast the peer has announced. origin::Consumer offers request_broadcast (needs a live route) and announced_broadcast (waits for an announcement), and the IETF subscriber only ever creates a broadcast::Producer from an incoming PUBLISH_NAMESPACE (ietf::Subscriber::start_announce).

The JS stack does not have this restriction. js/net/src/connection/established.ts exposes:

/**
 * Consume the broadcast at the given path, immediately.
 *
 * The subscription is reset if nobody publishes the path, so use
 * {@link announcedBroadcast} instead when the broadcast may not be online yet.
 */
consume(path: Path.Valid): broadcast.Consumer;

and js/watch/src/broadcast.ts uses it with the comment "No announcement gate: subscribe immediately", alongside a skipDiscovery() helper that warns "relay does not support broadcast discovery; subscribing to siblings blind."

There is no Rust equivalent, so any relay that does not announce is unreachable from every non-JS binding — including Cloudflare's provisioned moq-transport relays, which is the deployment README/the blog post point at.

Reproduction

Subscriber against a Cloudflare draft-16 relay (token in the URL path, as Cloudflare requires):

https://draft-16.cloudflare.mediaoverquic.com/<subscribe_token>   broadcast: timing.hang

Transport and version negotiation are perfect — this is purely the discovery step:

DEBUG web_transport_quinn::connect: sending CONNECT request request=ConnectRequest {
  path: "/<subscribe_token>",
  protocols: ["moq-lite-05", "moq-lite-04", "moq-lite-03", "moql", "moqt-20", "moqt-19",
              "moqt-18", "moqt-17", "moqt-16", "moqt-15", "moq-00"], headers: {} }
DEBUG web_transport_quinn::connect: received CONNECT response response=ConnectResponse { status: 200, protocol: Some("moqt-16") }
 INFO moq_native::client: connected version=moq-transport-16
TRACE moq_net::ietf::message: encoding self=SubscribeNamespaceLegacy { request_id: RequestId(0), namespace: Path(""), subscribe_options: 1 }
DEBUG moq_net::ietf::subscriber: subscribe_namespace sent prefix=
DEBUG moq_net::ietf::subscriber: subscribe_namespace ok prefix=
   (nothing further for 20s)
 INFO moq_net::ietf::session: session terminated

The relay accepts SUBSCRIBE_NAMESPACE with REQUEST_OK and then sends no PUBLISH_NAMESPACE/NAMESPACE at all, so request_broadcast("timing.hang") fails Unroutable and announced_broadcast("timing.hang") never resolves. Cloudflare's documented client is moq-sub --name my-namespace <url> — the subscriber names the namespace and subscribes blind.

The broadcast itself is fine, and the JS stack plays it. @moq/watch@0.5.3 in Chrome, same URL and same name="timing.hang", reaches connection.status == "connected", reports connection.discovery as unsupported (logging the warning quoted above), and still resolves the full catalog — H.264 CMAF 0.m4s plus AAC-LC 48 kHz CMAF 1.m4s — via consume(path).

Environment

  • dev.moq:moq-android:0.4.1 → dev.moq:moq-ffi-android:0.3.16 (prebuilt, arm64-v8a), Android 14 / API 34 device
  • Same conclusion reading rs/moq-net at tag moq-ffi-v0.3.16 and at main
  • Cloudflare provisioned relay, draft-16 endpoint, subscribe-only token

Possible directions

  1. ietf::Subscriber::consume(path) -> broadcast::Consumer, mirroring JS: create a broadcast::Producer, take its .dynamic() and drive the existing run_broadcast(path, dynamic) loop, which already issues the per-track SUBSCRIBE lazily. Surface it as moq_net::Session::consume(path) (with the lite subscriber's equivalent), then as MoqSession::consume(path) in moq-ffi.
  2. Or let the caller scope the subscriber origin, so subscribe_prefixes() asks for the namespace rather than "". origin::Producer::scope(&[Path]) already exists in Rust but is not reachable through moq-ffi (MoqOriginOptions carries only cache_capacity_bytes). Weaker than (1): it still depends on the relay answering SUBSCRIBE_NAMESPACE for a concrete prefix.
  3. Or a capability flag equivalent to JS's connection.discovery, so a client can at least tell "not announced yet" from "this peer never announces" instead of waiting out a timeout.

(1) looks like the one that matches the JS behaviour and unblocks every binding. Happy to send a PR for whichever shape you prefer.

Aside, possibly worth its own issue

Client::connect races the QUIC dial against the WebSocket fallback, and race_transport_connect returns early when either arm fails is_auth(). A WebTransport-only endpoint answers every non-WebTransport request with 403 (Cloudflare's does), so whenever the QUIC dial has not finished within websocket.delay (200 ms) the whole connect fails as Forbidden while the QUIC arm was still in flight — indistinguishable from a genuinely bad token. websocket.enabled is not exposed through moq-ffi, so an FFI consumer cannot opt out.

(Investigated with Claude Code.)

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    questBeing tracked/planned in a quest. See `quest/`

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions