moqx
moqx is an Elixir Media over QUIC library.
It provides a QUIC transport boundary backed by quicer for building MOQT implementations in Elixir. The transport boundary keeps protocol code independent from the concrete QUIC backend and allows tests to use deterministic support transports.
Protocol documents
moqx implements independent Cloudflare draft-14, standard MOQT draft-16, and
MoQ Lite draft-05 protocols over native QUIC. Protocol selection is explicit,
so the implementations coexist without hostname inference or fallback.
Core references:
- RFC 9000 — QUIC: A UDP-Based Multiplexed and Secure Transport
- RFC 9001 — Using TLS to Secure QUIC
- RFC 9002 — QUIC Loss Detection and Congestion Control
- RFC 9114 — HTTP/3
- RFC 9221 — QUIC DATAGRAM
- RFC 9297 — HTTP Datagrams and the Capsule Protocol
- draft-ietf-webtrans-http3-14 — WebTransport over HTTP/3
- draft-ietf-moq-transport-14 — Media over QUIC Transport
- draft-ietf-moq-transport-16 — Media over QUIC Transport
- draft-lcurley-moq-lite-05 — Media over QUIC Lite
The draft-16 interoperability reference is Moqtail's draft-16 branch pinned
at commit
c2ff7253479c6a0d7c8282a1cad289d591ebc302.
Application catalog profiles
Wire protocol selection is connection-scoped; catalog profiles are selected per
subscription or published catalog. Raw objects are the default, including on
.catalog, catalog, and catalog.json tracks. Existing CMSF consumers must
add an explicit profile to their MOQX.subscribe/3 call.
| Profile | Cloudflare draft-14 | MOQT draft-16 | MoQ Lite 05 | Catalog track |
|---|---|---|---|---|
:none | yes | yes | yes | any, opaque |
:cloudflare_cmsf | yes | yes | yes | .catalog |
:moqtail_cmsf | yes | yes | yes | catalog |
:hang | rejected | rejected | yes | catalog.json / catalog.json.z |
The matrix describes codec composition, not certification against every relay. HANG metadata is pinned, typed, and round-trippable, including unknown extension maps, hexadecimal decoder descriptions and base64 CMAF initialization. Unknown codec/container metadata does not imply playback support.
{:ok, publication} = MOQX.publish(publisher, ["live", "alice.hang"])
# Wait for PublicationReady before registering tracks.
{:ok, track} = MOQX.add_catalog(publisher, publication, profile: :hang)
{:ok, catalog} = MOQX.Catalog.decode("{}", format: :hang)
:ok = MOQX.publish_catalog(publisher, track, catalog)
{:ok, subscription} = MOQX.subscribe(subscriber, track.track, profile: :hang)
# CatalogReceived is receiver evidence; successful publish is local admission.
{:ok, discovery} = MOQX.discover(subscriber, "live/")
# BroadcastAvailable events precede DiscoveryReady; updates continue live.
:ok = MOQX.cancel_discovery(subscriber, discovery)
Each published snapshot uses a new group and is retained for late subscribers. HANG subscriptions replace their live snapshot and report track additions, removals and metadata changes. Malformed catalogs fail only the affected update; newer valid snapshots recover. Both encoded and expanded payloads default to 1 MiB limits. See module docs for options and address-error behavior, and the pinned interoperability evidence and limitations.
Installation
# mix.exs
{:moqx, "~> 0.8.0"}
Cloudflare draft-14 subscriber
Protocol selection is explicit; the endpoint never selects an implementation implicitly. Cloudflare's public Big Buck Bunny catalog can be requested with:
{:ok, client} =
MOQX.connect("moqt://draft-14.cloudflare.mediaoverquic.com:443",
protocol: :cloudflare_draft_14
)
catalog_track = %MOQX.TrackRef{namespace: ["bbb"], track: ".catalog"}
{:ok, subscription} = MOQX.subscribe(client, catalog_track, profile: :cloudflare_cmsf)
receive do
{:moqx, ^client,
%MOQX.Event.CatalogReceived{catalog: %MOQX.Catalog{} = catalog}} ->
catalog.tracks
end
This path uses native QUIC with ALPN moq-00, negotiates MOQT draft-14,
subscribes with LargestObject, and decodes the CMSF catalog delivered on a
subgroup stream. It does not use FETCH.
Subscriptions accept a protocol-neutral relative start policy:
{:ok, subscription} =
MOQX.subscribe(client, track,
start: :next_group
)
:next_object is the compatibility default and maps to draft-14
LargestObject; :next_group maps to NextGroupStart. A selected protocol
that cannot represent a requested policy returns
{:error, {:unsupported_subscription_start, policy}} instead of silently
substituting another boundary.
MoQ Lite draft-05 subscriber and publisher
Select MoQ Lite explicitly with protocol: :moq_lite_05. It uses native QUIC
ALPN moq-lite-05, sends a unidirectional Setup Stream with Path and Role, and
implements Announce, Track, Subscribe, and reliable Group Streams through the
same public API as the other implementations:
{:ok, client} =
MOQX.connect("moql://relay.example/live",
protocol: :moq_lite_05,
role: :publisher
)
{:ok, publication} = MOQX.publish(client, ["live"])
{:ok, video} =
MOQX.add_track(client, publication, "video",
timescale: 90_000,
publisher_priority: 127,
publisher_max_latency: 1_000
)
:ok =
MOQX.publish_object(client, video, %MOQX.Object{
group_id: 42,
object_id: 0,
timestamp: 3_780_000,
end_of_group?: true,
payload: frame
})
One registered track can be withdrawn without ending its publication or its siblings:
:ok =
MOQX.withdraw_track(client, video,
status: :track_ended,
reason: "source ended"
)
The handle becomes stale before the call returns. Pending requests for that
exact track are rejected, active subscribers receive their protocol's normal
terminal action, and PublicationSubscriberLeft is emitted once per active
subscriber. Re-registering the same track name returns a fresh handle. A
foreign handle returns :wrong_client_published_track; stale, repeated, and
already-finished handles return :unknown_published_track. Invalid reasons or
option keys return :invalid_track_completion, while an unsupported status
returns :unsupported_completion_status. MoQ Lite draft-05 supports the
:track_ended status; draft-14 and draft-16 map the protocol-neutral statuses
to their native PUBLISH_DONE codes.
timescale is required and positive. Publisher priority is a byte and maximum
latency is a bounded QUIC varint. Received objects preserve FRAME timestamps
independently from group and object identifiers. Automatic, controlled, and
reactive inbound subscription handling use the existing opaque request and
published-subscription handles; a Group Stream is kept open across objects
until end_of_group?: true.
Demand-driven Lite track provisioning
Relays may fetch immutable TrackInfo before issuing a subscription. To create absent tracks on demand, opt into metadata provisioning separately from admission:
{:ok, publication} =
MOQX.publish(client, ["live"],
missing_track_metadata: :controlled,
track_metadata_timeout: 5_000,
max_pending_track_metadata: 128,
inbound_subscriptions: :controlled
)
receive do
{:moqx, ^client, %MOQX.Event.PublicationTrackRequested{request: request}} ->
# The application chooses whether/how to provision this exact track.
{:ok, track} =
MOQX.add_track(client, request.publication, request.track.track, timescale: 90_000)
end
The connection's event recipient owns these requests and its exit closes the
connection. Metadata handles are connection-scoped, per request, and bounded by
a per-publication limit and a deadline starting on receipt. Registering a track
answers every pending metadata request for that exact publication/name using
the registered properties. It does not approve a subscription: applications
must still handle PublicationSubscriptionRequested explicitly. Reactive
accept_subscription/3 also resolves concurrent metadata requests when it
registers their track. No application factory or retry loop runs inside MOQX.
reject_track_request/3 accepts a SubscriptionRejection and affects only the
chosen request. Lite transmits its code, not its textual reason. Decided,
cancelled, and foreign handles return :stale_track_request. Each admitted
request emits one PublicationTrackRequestDone: :registered, :reply_failed, :rejected,
:timed_out, :peer_cancelled, :invalid_request, :publication_finished, or
:connection_closed. An exited owner cannot receive terminal events.
Registration commits locally and returns a usable track even if an individual
metadata reply fails. Each pending reply is attempted independently. :registered
means transport admission, not peer delivery; :reply_failed carries the transport
error in error. Best-effort stream cleanup cannot suppress these outcomes or
skip sibling replies. Ordinary non-metadata transport actions remain fail-fast.
The default missing_track_metadata: :reject keeps immediate missing-track
rejection. Unknown broadcasts and capacity overflow also reset immediately
without creating application handles. Other protocols reject the Lite metadata
options explicitly. Existing applications need no migration unless they choose
this opt-in provisioning lifecycle.
The pinned-relay contract is executable with:
MOQX_LITE_ENDPOINT=moql://127.0.0.1:24463/ \
MOQX_LITE_CA_FILE=/path/to/ca.pem \
mise exec -- mix test test/integration/moq_lite_05_metadata_demand_test.exs --include integration
Caller-managed relay: moq-dev/moq revision
fd477082c43c3c0738fb62d077d85ea078f10045, native ALPN moq-lite-05, verified
TLS. Tests cover acceptance with exact metadata/payload/timestamp and completion,
explicit rejection, and timeout. This is functional evidence, not throughput or
application playback certification. See ADR-0015 for the ownership boundary.
The default subscriber request resolves the publisher's latest group. Draft-05 can also represent absolute group starts and ranges whose object coordinate is zero. Unsupported relative starts, non-zero object coordinates, parameters, and delivery modes return typed errors rather than changing their meaning. WebTransport, Fetch, Probe, datagram delivery, and draft-06 are not part of this implementation.
Lite05 completion follows moq.dev's corrected exclusive SUBSCRIBE_END contract:
END N permits groups below N; END 0 means an empty track. This deliberately
differs from the submitted IETF -05 text, following upstream PR #2333 with
maintainer approval. The wire decision and references are documented in
the interoperability notes and in code.
The native-QUIC endpoint scheme is moql://; the older moqt:// spelling
remains accepted for existing callers. MoQ Lite draft-05 requires the QUIC
DATAGRAM transport parameter even when an application only publishes reliable
Group Streams, so MOQX advertises receive support without changing the public
delivery API.
Standard draft-16 subscriber and publisher
Moqtail's public relay can be reached through the independent :draft_16
implementation. Subscription and catalog reception are available against the
public relay:
{:ok, client} =
MOQX.connect("moqt://relay.moqtail.dev:443",
protocol: :draft_16
)
catalog_track =
%MOQX.TrackRef{
namespace: ["moqtail", "testsrc"],
track: "catalog"
}
{:ok, subscription} =
MOQX.subscribe(client, catalog_track,
profile: :moqtail_cmsf,
start: :next_group,
priority: 127
)
receive do
{:moqx, ^client,
%MOQX.Event.CatalogReceived{
subscription: ^subscription,
catalog: %MOQX.Catalog{} = catalog
}} ->
catalog
end
Draft-16 publication uses two readiness boundaries. PublicationReady means
the relay accepted the namespace. Each added track then sends draft-16
PUBLISH; objects are accepted only after the corresponding
PublicationSubscriberJoined event confirms PUBLISH_OK:
{:ok, publication} = MOQX.publish(client, ["example", "camera"])
receive do
{:moqx, ^client,
%MOQX.Event.PublicationReady{publication: ^publication}} ->
:ok
end
{:ok, video} =
MOQX.add_track(client, publication, "video",
delivery: :datagram
)
receive do
{:moqx, ^client,
%MOQX.Event.PublicationSubscriberJoined{track: ^video}} ->
MOQX.publish_object(client, video, object)
end
:ok = MOQX.finish_publication(client, publication)
delivery: :subgroup is the default and opens one subgroup stream per object.
delivery: :datagram emits draft-16 unified object datagrams and reports zero
opened streams when the track completes. The delivery choice also applies to
relay-initiated subscribers of that track. Cloudflare draft-14 rejects
:datagram explicitly because that implementation supports subgroup
publication only.
finish_publication/3 first cancels pending controlled requests with
REQUEST_ERROR(DOES_NOT_EXIST), then completes established relay subscriptions
and ready publisher-initiated tracks with PUBLISH_DONE and their exact
opened-stream counts. It sends PUBLISH_NAMESPACE_DONE only after those
subscription boundaries. Applications receive
PublicationSubscriptionCancelled and PublicationSubscriberLeft for the
affected requests, and their handles become stale immediately.
Namespace rejection/cancellation and per-track rejection emit
PublicationFailed, PublicationCancelled, and PublicationTrackFailed
respectively; rejected and finished handles are invalidated deterministically.
Incoming draft-16 SUBSCRIBE requests use the same
inbound_subscriptions: :automatic | :controlled publication policy and the
same opaque request, accept, reject, timeout, joined, and left events as the
draft-14 implementation. Accepted subscribers receive their own track alias
and the track's selected subgroup or datagram delivery; UNSUBSCRIBE completes
that subscriber with the exact stream count.
The operator workflow below was verified against relay.moqtail.dev and
player.moqtail.dev on 2026-07-28: the player discovered the CMSF catalog,
selected the advertised H.264 track, reached Playing, decoded 640×360 video,
and advanced its media clock while the publisher completed cleanly. Those
services can change independently, so rerun the smoke for current deployment
evidence.
This path negotiates ALPN moqt-16, sends native-QUIC PATH and AUTHORITY
setup parameters, and decodes draft-16 subgroup streams and object datagrams.
Objects preserve extension headers and end-of-group metadata. Objects remain opaque by default. Select profile: :moqtail_cmsf on a
subscription to decode current Moqtail CMSF values as CatalogReceived events.
Draft-16 also accepts the complete protocol-neutral filter model:
filter = %MOQX.SubscriptionFilter{
type: :absolute_range,
start_location: {12, 4},
end_group: 20
}
{:ok, subscription} =
MOQX.subscribe(client, track,
filter: filter,
priority: 127,
group_order: :ascending,
delivery_timeout: 5_000
)
:ok =
MOQX.update_subscription(client, subscription,
start: :next_group,
priority: 64
)
The relative :start policies remain the portable API shared with Cloudflare.
Absolute start/range filters, request updates, datagrams, and accepted
subscription parameters are currently implemented by :draft_16. Update
success and rejection arrive as SubscriptionUpdated and
SubscriptionUpdateFailed; an update rejection leaves the subscription
active.
Current Moqtail catalogs use top-level role, packaging, codec, dimensions,
bitrate, and timescale fields. Per-track base64 initData is validated and
decoded into track.init_data. The catalog subscription namespace is retained
when track entries omit one, so the selected address is exact:
{:ok, video} = MOQX.Catalog.select_h264(catalog)
media_ref = MOQX.Catalog.track_ref(catalog, video)
{:ok, subscription} = MOQX.subscribe(client, media_ref)
receive do
{:moqx, ^client,
%MOQX.Event.ObjectReceived{
object: %MOQX.Object{subscription: ^subscription} = object
}} ->
object.payload
end
H.264 selection is deterministic: compatible initialized tracks are ordered by
resolution, bitrate, then track name. Invalid versions, field types, supported
values, and base64 return %MOQX.Catalog.Error{path: path, reason: reason}.
For CMAF H.264, MOQX.CMAF.capture/4 uses Moqtail inline initialization bytes
or subscribes to Cloudflare's separately advertised initialization track. It
then subscribes to the exact media address, orders received objects by their
protocol coordinates, writes a fragmented MP4 atomically, and unsubscribes its
temporary subscriptions:
{:ok, report} =
MOQX.CMAF.capture(client, catalog, "/tmp/cloudflare-bbb.mp4",
objects: 120,
timeout: 30_000
)
The runnable external example performs the complete flow:
mix run scripts/cloudflare_h264_capture.exs /tmp/cloudflare-bbb.mp4 120
ffprobe -v error -show_streams /tmp/cloudflare-bbb.mp4
ffmpeg -y -i /tmp/cloudflare-bbb.mp4 -map 0:v:0 -c:v copy \
-bsf:v h264_mp4toannexb -an -f h264 /tmp/cloudflare-bbb.h264
ffmpeg -v error -f h264 -i /tmp/cloudflare-bbb.h264 -f null -
MOQX.unsubscribe/2 sends the selected protocol's unsubscribe message;
MOQX.close/2 closes the connection. Relay rejections are delivered as
MOQX.Event.SubscriptionFailed, while MOQX.Event.SubscriptionDone is emitted
only after every stream advertised by PUBLISH_DONE has been processed or the
subscription's :delivery_timeout has elapsed.
Objects are emitted in normalized transport arrival order. Objects within one subgroup preserve their stream order, but no global coordinate or group order is manufactured across independent subgroup streams.
Each subgroup stream emits a typed boundary after all preceding object/status events:
receive do
{:moqx, ^client,
%MOQX.Event.SubgroupEnded{
subscription: ^subscription,
group_id: group_id,
subgroup_id: subgroup_id,
outcome: :complete
}} ->
{group_id, subgroup_id}
end
Cloudflare's catalog convention remains separate: .catalog,
commonTrackFields, codec values under selectionParams, and initTrack.
Both shapes normalize into %MOQX.Catalog{} without changing their
initialization lifecycle; catalog.format is :cloudflare or
:moqtail_cmsf.
:complete means FIN proved the subgroup complete. :reset means more objects
may exist and does not end the subscription; :closed means completeness is
unknown. SubscriptionDone never overtakes an accepted subgroup boundary.
Datagrams have no subgroup boundary. Applications requiring stronger ordering
own and bound their reorder buffer and gap policy; see ADR-0011.
All application-facing output uses typed MOQX.Event.* structs inside the
stable {:moqx, client, event} envelope. By default events go to the process
that calls MOQX.connect/2; shared connection owners can choose a router:
{:ok, client} =
MOQX.connect(endpoint,
protocol: :cloudflare_draft_14,
events_to: router_pid
)
Downstream projects can run hermetic protocol tests with the packaged in-memory transport. It must be selected explicitly and is never chosen by production facade code:
{:ok, network} = MOQX.Testing.Transport.start_network()
MOQX.connect("moqt://localhost:443",
protocol: :cloudflare_draft_14,
transport: {MOQX.Testing.Transport, network: network, profile: :draft_14}
)
Cloudflare draft-14 publisher
Publishing uses the same explicitly selected client. Applications declare a namespace and tracks, then supply protocol-neutral objects; Cloudflare request IDs, track aliases, and inbound relay subscriptions remain implementation details:
{:ok, publication} = MOQX.publish(client, ["live", "camera-1"])
{:ok, video} =
MOQX.add_track(client, publication, "video.m4s", retention: :live)
:ok =
MOQX.publish_object(client, video, %MOQX.Object{
group_id: 42,
subgroup_id: 0,
object_id: 0,
publisher_priority: 127,
payload: fragment
})
:ok = MOQX.finish_publication(client, publication)
Retention is application policy: :live discards objects when no subscriber
is active, :latest retains one object for catalog or initialization tracks,
and :all replays bounded static content.
Inbound subscriptions are accepted automatically by default. A publisher can instead inspect, authorize, and provision each request before deciding it:
{:ok, publication} =
MOQX.publish(client, ["live", "camera-1"],
inbound_subscriptions: :controlled,
subscription_decision_timeout: 5_000,
max_pending_subscriptions: 128
)
receive do
{:moqx, ^client,
%MOQX.Event.PublicationSubscriptionRequested{request: request}} ->
{:ok, video} =
MOQX.add_track(client, publication, request.track.track, retention: :live)
{:ok, published_subscription} =
MOQX.accept_subscription(client, request, video)
end
For a draft-16 namespace-forwarded request whose track does not yet exist,
acceptance can materialize the requested track without sending a conflicting
publisher-initiated PUBLISH. The call returns the track and its first
accepted subscription as separate opaque handles:
{:ok, video, published_subscription} =
MOQX.accept_subscription(client, request,
retention: :live,
delivery: :subgroup
)
PublicationSubscriberJoined and PublicationSubscriberLeft carry the same
PublishedSubscription handle in their subscription field. The legacy
wire-derived request_id field remains temporarily available for
compatibility, but application lifecycle state should use the opaque handle.
An application can finish exactly one accepted subscriber without withdrawing the published track or namespace:
:ok =
MOQX.finish_subscription(client, published_subscription,
status: :subscription_ended,
reason: "source unavailable"
)
Both supported implementations translate the status to their native
PUBLISH_DONE, include the exact number of opened subgroup streams, emit
PublicationSubscriberLeft, and invalidate the handle. Remote UNSUBSCRIBE
converges on the same terminal event and handle lifecycle. Duplicate, stale,
and cross-connection handles return deterministic errors.
MOQX.reject_subscription/3 accepts a protocol-neutral
MOQX.SubscriptionRejection. Pending requests are connection-scoped, bounded
by the configured count and timeout, and are invalidated by unsubscribe,
publication termination, or connection closure. Request events preserve
priority, forward state, group order, all four draft-14 filters, repeated
authorization parameters, delivery timeout, and unknown extensions.
Controlled acceptance supports ascending delivery. A request for descending
delivery remains pending and accept_subscription/4 returns
{:error, :unsupported_group_order}; the application should reject it with
:not_supported. Publisher-selected order defaults to ascending and can be
confirmed explicitly with group_order: :ascending in the acceptance options.
MOQX.CMAF.publish_file/3 prepares a fragmented MP4 using the selected
protocol's catalog convention. Cloudflare draft-14 uses .catalog, a separate
initialization track, and retained media fragments. Standard draft-16 waits for
namespace and track readiness, publishes a Moqtail-compatible catalog with
inline initData, then publishes retained media on video:
{:ok, published} =
MOQX.CMAF.publish_file(client, "/tmp/input.mp4",
namespace: ["live", "camera-1"],
catalog_repetitions: 10,
catalog_interval: 1_000,
fragment_interval: 1_000
)
The repository includes an opt-in finite publisher for the public Moqtail draft-16 relay and player. It prints the player URL before publication starts, repeats only the catalog during the discovery window, and sends each media fragment once so embedded CMAF decode timestamps remain monotonic:
ffmpeg -i input.mp4 -an -c:v libx264 -profile:v baseline -level 3.1 \
-g 30 -keyint_min 30 -sc_threshold 0 \
-movflags +frag_keyframe+empty_moov+default_base_moof \
-frag_duration 1000000 -f mp4 /tmp/input-fragmented.mp4
mise exec -- mix run scripts/moqtail_cmaf_publish.exs \
/tmp/input-fragmented.mp4 \
--endpoint moqt://relay.moqtail.dev:443 \
--namespace moqx/unique-camera \
--catalog-repetitions 10 \
--catalog-interval 1000 \
--fragment-interval 1000
Open the printed https://player.moqtail.dev URL during the catalog discovery
window. The input must be fragmented H.264 CMAF; the codec, dimensions,
bitrate, timescale, and fragment pacing options must describe that file. This
manual workflow is not part of ordinary mix test, and local success alone is
not evidence of public relay/player playback.
Managed relay credentials are explicit caller input. The credential value is wrapped so both its value and the resulting sensitive wire actions have redacted inspection:
{:ok, client} =
MOQX.connect(endpoint,
protocol: :cloudflare_draft_14,
authorization: MOQX.Secret.new(token)
)
MOQX encodes that value using draft-14's standard AUTHORIZATION TOKEN parameter. Token acquisition, permissions, storage, and rotation remain relay and application concerns; MOQX does not read process or application configuration for credentials.
The manual publisher/subscriber roundtrip accepts a token file so the token is not placed in shell history. Omit it for Cloudflare's public relay:
mix run scripts/cloudflare_h264_publish.exs /tmp/input.mp4 \
--endpoint moqt://draft-14.cloudflare.mediaoverquic.com:443 \
--namespace moqx-test/unique-publisher \
--output /tmp/roundtrip.mp4 \
--timeout 120000
# For a managed relay, additionally pass:
# --authorization-file /path/to/temporarily-mounted-token
ffprobe -v error -show_streams /tmp/roundtrip.mp4
ffmpeg -v error -i /tmp/roundtrip.mp4 -map 0:v:0 -f null -
Development
mix deps.get
mix test
mix ci
Default tests are fast and hermetic. Real QUIC checks are tagged as ExUnit integration tests and are excluded by default.
The public Moqtail draft-16 subscriber smoke is independently selectable:
mix test --only integration \
test/integration/moqtail_draft_16_catalog_test.exs
The repo-owned draft-16 harness builds Moqtail's relay and test publisher at
the immutable revision
c2ff7253479c6a0d7c8282a1cad289d591ebc302, then verifies the ordinary MOQX
public subscriber API over local QUIC with generated TLS:
scripts/run_moqtail_draft16_integration.sh
This pinned harness is independent of the public relay smoke and does not run
during ordinary mix test.
The self-contained public Cloudflare subscription-start check publishes a
unique namespace, subscribes with :next_group, and records the deployed
relay's boundary behavior. It is independently selectable and depends on the
availability of an external service:
mix test --only integration \
test/integration/cloudflare_subscription_start_test.exs
The separate test/integration/cloudflare_catalog_test.exs smoke depends on
Cloudflare's optional bbb/.catalog fixture being published.
The repo-owned Cloudflare draft-14 roundtrip runs both MOQX and a real relay in Docker. It publishes a catalog and media object through the public API, subscribes through a second public client, verifies delivery, and exercises graceful publication completion:
scripts/run_moq_rs_integration.sh
The harness builds Cloudflare's moq-rsdraft-ietf-moq-transport-14 branch at
the immutable revision 69302d3dc2422e93b8a1d62f853a6759aa9e5468. Do not
replace that pin with main: upstream main has moved to a later MOQT draft
and no longer negotiates the draft-14 moq-00 ALPN. The MOQX test runner joins
the Compose network directly so the QUIC path is identical on Docker Desktop
and Linux CI rather than depending on host UDP forwarding.
That pinned relay decodes NextGroupStart but does not apply the filter when
attaching its retained subgroup reader: it can replay the current retained
group before delivering a later group. The integration test records this relay
limitation explicitly. Fixed wire and reducer tests establish that MOQX sends
draft-14 filter value 0x1; applications must not treat this relay version as
proof that the peer enforced the requested boundary.
ExUnit never starts Docker. The script owns Compose startup and cleanup, and
the same script is the Cloudflare draft-14 relay roundtrip CI job. Future
relay variants should add separately pinned Compose services,
tagged public-API tests, and runner scripts following this boundary; they must
not add an implicit protocol fallback or overload this Cloudflare test.
The MoQ Lite draft-05 interoperability harness builds Curley's official relay
and moq CLI from the same immutable source revision
fd477082c43c3c0738fb62d077d85ea078f10045 (the moq-relay-v0.14.15 and
moq-cli-v0.10.0 release commit). It uses generated loopback TLS certificates
and real native QUIC inside the Compose network:
scripts/run_curley_moq_lite_05_integration.sh
The integration matrix verifies an exact timestamped payload through MOQX in both roles, then runs the official Curley CLI as an independent H.264 publisher and subscriber in the opposite directions. A one-shot probe built against that same pinned Curley revision independently asserts the received raw FRAME timestamp while using a different timestamp inside the legacy H.264 payload. It also verifies relay fan-out and final-subscriber lifecycle: A can leave while B continues receiving; B's final explicit departure produces the upstream leave within 5 seconds; an abrupt final disconnect also leaves; and a later C receives a fresh group without a publisher restart. The configured publisher maximum latency remains the issue's 45-second upper bound. The complete local matrix currently finishes in under a second after image startup.
The public cdn.moq.dev check is intentionally opt-in. It publishes a unique,
anonymous broadcast below /anon, waits for clustered route propagation, and
subscribes through a separate MOQX connection. It then observes the bounded
final-subscriber leave and proves that a fresh subscriber receives a later
group without restarting the publisher:
scripts/run_curley_moq_lite_05_public.sh
On 2026-09-03 the container resolved cdn.moq.dev to IPv4
172.232.208.199; the DNS resolver returned no IPv6 address. MOQX passes the
hostname unchanged to Quicer/MsQuic, so address selection and any transport
fallback remain below the protocol abstraction rather than being implemented
as a second MOQX connection policy. The test prints current IPv4 and IPv6
resolution on every run. Public availability and DNS can change independently,
which is why this check is not part of the hermetic CI job.
To run the caller-managed QUIC integration harness:
docker compose -f docker-compose.integration.yml up -d --wait
mix test --only integration
ExUnit does not start Docker. Stop the harness when finished:
docker compose -f docker-compose.integration.yml down
The harness provisions self-signed certificates under
.tmp/integration-certs/ (via scripts/gen-loopback-certs.sh) and runs the
repo-owned reference QUIC server from bench/quicprobe on UDP port 4433. The
generated CA/server certificate is valid for ~100 years — it only authenticates
a localhost QUIC handshake, so it is intentionally long-lived to avoid expiry
friction. To (re)generate the loopback certificates outside the harness:
scripts/gen-loopback-certs.sh .tmp/integration-certs
The script is idempotent: it reuses an existing certificate unless it is missing or nearly expired.
For manual debugging, run the reference CLI directly:
go run ./bench/quicprobe server --addr :4433 \
--cert .tmp/integration-certs/server.pem \
--key .tmp/integration-certs/server-key.pem \
--alpn moqx-test
go run ./bench/quicprobe client --addr 127.0.0.1:4433 \
--ca .tmp/integration-certs/ca.pem \
--alpn moqx-test \
--bidi-echo hello
For reference stream-pressure experiments, the client can emit structured
quicprobe-v1 JSON:
go run ./bench/quicprobe client --addr 127.0.0.1:4433 \
--ca .tmp/integration-certs/ca.pem \
--alpn moqx-test \
--json \
--stream-direction bidirectional \
--stream-count 2 \
--payload-size 1200 \
--payload-count 100
License
MIT