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:

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.

ProfileCloudflare draft-14MOQT draft-16MoQ Lite 05Catalog track
:noneyesyesyesany, opaque
:cloudflare_cmsfyesyesyes.catalog
:moqtail_cmsfyesyesyescatalog
:hangrejectedrejectedyescatalog.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