Broker compatibility
Pulse.Mqtt is verified against three brokers on every change through a shared conformance suite. Each broker runs the same scenarios — defined once in BrokerScenarios — so a failure names the broker and the capability that regressed rather than a one-off test.
Tested brokers
| Broker | Version tested | Anonymous access | How it runs |
|---|---|---|---|
| Eclipse Mosquitto | 2.x (eclipse-mosquitto:2) | mosquitto-no-auth.conf | Every PR and every merge |
| EMQX | 5.8 (emqx/emqx:5.8) | Default listener | PRs that touch source, merges to main, on demand |
| EMQX over QUIC | 5.8, QUIC listener on 14567/udp | Default listener | Same as EMQX; needs msquic on the machine |
| HiveMQ CE | 2024.3 (hivemq/hivemq-ce:2024.3) | Default listener | PRs that touch source, merges to main, on demand |
The brokers run as Testcontainers images, so the suite needs only a working Docker daemon — no broker installs, no shared test environment.
Verified scenarios
Each broker passes all of the following:
| Scenario | What it proves |
|---|---|
| Handshake | CONNECT/CONNACK succeeds and a clean DISCONNECT is accepted. |
| QoS 0 round trip | Fire-and-forget delivery. |
| QoS 1 round trip | At-least-once delivery with PUBACK. |
| QoS 2 round trip | Exactly-once delivery through the full PUBREC/PUBREL/PUBCOMP exchange. |
| Retained message | A retained publish reaches a subscriber that connects after it. |
| Shared subscription | $share/<group>/<topic> delivers each message to exactly one group member. |
| Large payload | A 64 KB payload round-trips intact. |
| Persistent session resume | Reconnecting with CleanStart = false and a non-zero session expiry resumes the session (SessionPresent = true) and still routes its earlier subscription. |
| Receive-maximum flow control | A QoS 2 burst larger than the broker's receive maximum completes and arrives exactly once — the client holds a send-quota slot per exchange until PUBCOMP, so it never exceeds the broker's limit. |
| Topic aliases | A repeated topic is compressed into an MQTT 5 topic alias after the first publish; the broker accepts the alias and still routes every message to the full topic. |
TLS is covered separately by a dedicated integration test (TlsIntegrationTests) that round-trips a message over a real TLS connection to a Mosquitto broker configured with a generated self-signed certificate.
The EMQX-over-QUIC row runs the identical scenario suite through the QUIC transport against the same EMQX container's QUIC listener, so transport differences cannot hide behind broker differences.
How the matrix is gated in CI
Running the heavier EMQX and HiveMQ images on every push would slow the inner loop, so the matrix is split:
ci.ymlruns on every PR and push with--filter "Category!=BrokerMatrix". Mosquitto and all other tests run; the EMQX/HiveMQ classes are skipped, keeping the fast lane quick.broker-matrix.ymlruns--filter "Category=BrokerMatrix"(EMQX and HiveMQ) on PRs that touch the source, the integration tests, or the shared build inputs — so a cross-broker regression is caught before merge — and again onmainand via manual dispatch. A matrix failure onmainopens (or updates) a tracking issue, since it can't block the merge that caused it.
The EMQX and HiveMQ test classes carry [Trait("Category", "BrokerMatrix")]; Mosquitto does not, so it always runs.
Running the matrix locally
# Mosquitto only (the fast inner loop)
dotnet test tests/Pulse.Mqtt.IntegrationTests --filter "Category!=BrokerMatrix"
# EMQX and HiveMQ (pulls the broker images on first run)
dotnet test tests/Pulse.Mqtt.IntegrationTests --filter "Category=BrokerMatrix"