Skip to content

Road to 1.0

What 1.0 means for this library, every feature it requires, the nice-to-haves around it, and a testable definition of done for each step. Items marked gap were verified against the current source, not assumed.

What 1.0 promises

  1. Spec-complete client behavior for MQTT 5.0 and 3.1.1 — not just packet encoding, but the negotiated-limit and lifecycle behaviors a broker expects from a conforming client.
  2. A frozen public API under semantic versioning: no breaking changes until 2.0.
  3. Proven interop against the major brokers, not one.
  4. The existing quality gates stay green: zero build warnings, Native AOT with zero trim/AOT warnings, benchmarks at or better than current numbers, all tests deterministic.

Current state (0.2.0): full v5 + 3.1.1 codec, TCP/TLS/WebSocket/loopback transports, raw and resilient clients, routing, typed messaging, RPC, DI, health checks, observability, the in-process test broker, fluent API, docs site, CI with trusted publishing and prerelease-on-merge. v5 subscription options (NoLocal, RetainAsPublished, RetainHandling) are already complete.


Required for 1.0

F1 — Broker-initiated DISCONNECT handling (gap)

The connection layer currently ignores an inbound DISCONNECT; the client only notices the socket closing. A conforming client must treat DISCONNECT as an orderly close and surface the broker's reason.

Definition of done

  • [ ] An inbound DISCONNECT closes the session without treating it as a protocol error.
  • [ ] The reason code and reason string reach the supervisor: the ConnectionStateChanged transition and OnConnectionDownAsync carry them; the state log includes them.
  • [ ] Reason codes that indicate a permanent condition (for example NotAuthorized, ServerMoved, UseAnotherServer) flow through IReconnectDecision so the default faults instead of hammering; transient reasons reconnect as usual.
  • [ ] ServerReference from DISCONNECT (and CONNACK) is exposed on the down-context for redirect-aware deployments.
  • [ ] Unit tests cover: graceful disconnect mid-traffic, disconnect during an in-flight QoS 2 exchange, disconnect with each terminal reason; an integration test provokes a real broker disconnect (for example keep-alive violation).

F2 — Receive Maximum flow control, outbound (gap)

The broker's CONNACK ReceiveMaximum caps how many QoS > 0 publishes may be unacknowledged at once. The client currently bounds in-flight work only by packet-identifier availability.

Definition of done

  • [ ] Outbound QoS 1/2 publishes never exceed the broker's ReceiveMaximum; excess callers wait (bounded, cancellable) rather than erroring.
  • [ ] The limit re-arms per connection from each CONNACK; a reconnect to a broker with a different limit honors the new one.
  • [ ] No throughput regression at the default Mosquitto limit (sustained-throughput benchmark within noise of current numbers).
  • [ ] Unit tests with a scripted broker prove: the (N+1)th publish waits until an ack frees a slot; cancellation while waiting releases cleanly; QoS 0 is unaffected.

F3 — Maximum Packet Size compliance, outbound (gap)

The broker's CONNACK MaximumPacketSize bounds what the client may send. Today nothing enforces it, which can get the client disconnected with PacketTooLarge.

Definition of done

  • [ ] A publish whose encoded size exceeds the negotiated limit fails fast with a clear, typed error before any bytes hit the wire — never a broker disconnect.
  • [ ] The check accounts for the full encoded packet (header, properties, payload), not just the payload length.
  • [ ] The client-side MaximumPacketSize request in CONNECT remains honored for inbound (oversized inbound already faults via the framing limit).
  • [ ] Unit tests cover: exactly at the limit (sent), one byte over (rejected), no limit advertised (unlimited up to the protocol maximum).

F4 — Topic aliases (gap)

Bandwidth saver for repeated topics, both directions. Inbound resolution is mandatory the moment the client advertises a TopicAliasMaximum; outbound use is bounded by the broker's.

Definition of done

  • [ ] Inbound: when the client advertises TopicAliasMaximum, publishes carrying an alias with an empty topic resolve to the registered topic; an unknown or out-of-range alias is a protocol error per the specification.
  • [ ] Outbound: an opt-in policy assigns aliases to the hottest topics within the broker's advertised maximum; once aliased, repeat publishes send the alias and an empty topic.
  • [ ] Aliases reset per connection (both directions) — nothing leaks across reconnects.
  • [ ] Off by default outbound; one option enables it. Inbound resolution is automatic.
  • [ ] Unit tests cover registration, reuse, reset on reconnect, and the two inbound error cases; a benchmark shows the bytes-per-message win on a repeated-topic workload.

F5 — In-flight redelivery on session resume (gap)

With CleanStart = false and a broker that preserved the session, the spec requires the client to retransmit unacknowledged QoS 1/2 publishes (DUP set) and unacknowledged PUBRELs. Today in-flight operations fail on connection loss and only never-sent messages flush from the offline queue.

Definition of done

  • [ ] Unacknowledged QoS 1/2 PUBLISH and PUBREL packets persist in session state and retransmit, in original order, before the offline queue flushes, when the broker reports SessionPresent.
  • [ ] Retransmitted PUBLISH packets carry DUP; packet identifiers are reused correctly and not returned to the allocator until the exchange completes.
  • [ ] When the broker did not preserve the session, in-flight state is discarded and the awaiting callers fail with a clear error (current behavior, now explicit and tested).
  • [ ] Inbound QoS 2 duplicate suppression state survives the reconnect for the same session.
  • [ ] The state lives behind the existing ISessionStore swap point so durable stores can persist it; the in-memory default keeps it for the process.
  • [ ] Scripted-broker tests cover: drop after PUBLISH/before PUBACK, drop between PUBREC and PUBREL, drop after PUBREL/before PUBCOMP — each resumes to completion; an integration test proves QoS 2 end-to-end across a real reconnect with a persistent session.

F6 — Enhanced authentication (AUTH) (gap)

The v5 AUTH exchange (SCRAM, OAuth-style challenges, Kerberos). The packet codec exists; the connection layer ignores AUTH packets.

Definition of done

  • [ ] A new swap point (an authenticator contract) handles challenge/response: it sees each broker AUTH/CONNACK step for its method and produces the next AUTH packet until success or failure.
  • [ ] The handshake supports ReAuthenticate-initiated exchanges on a live connection.
  • [ ] No authenticator configured = exactly today's behavior (no AUTH sent, no overhead).
  • [ ] Unit tests drive a scripted multi-step exchange to success and to rejection; rejection maps to a terminal connect failure through the existing decision flow.

F11 — Presence: last-will and birth messages, static and factory forms

Both halves of device presence. The last will is the message the broker publishes on the client's behalf when the connection dies ungracefully — it already encodes on CONNECT but is reachable only through the raw packet today. The birth message (the "first will") is its mirror: a message the client itself publishes automatically on every connection-up, announcing it is online. Together with a retained topic they give the classic presence pattern:

status/device-7   ← birth publishes "online" (retained) on every connect
status/device-7   ← will publishes "offline" (retained) when the client drops

Static forms cover the common case; factory forms compute the topic and payload fresh for each connection attempt — session counters, timestamps, current configuration — instead of freezing them at startup.

Definition of done

  • [ ] Last will, static: PulseMqttClientOptions (DI) and PulseMqttClientBuilder.WithWill(...) (fluent) express topic, payload, QoS, retain, will-delay interval, and the v5 will properties, without touching the raw packet.
  • [ ] Last will, factory: a per-connection factory (CancellationToken → ValueTask<MqttWillMessage>) is invoked on every connection attempt before CONNECT is sent, so each reconnect can carry a fresh topic and payload. A throwing factory fails that attempt like any connect failure — classified by the reconnect decision, never swallowed.
  • [ ] Birth, static: an announcement message (topic, payload, QoS, retain) the client publishes automatically on every connection-up — after re-subscription, before the offline queue flushes, before the state becomes Connected — so observers never see "online" from a client whose session is not actually restored yet.
  • [ ] Birth, factory: the same per-connection factory form, invoked at publish time with the connection attempt visible, so payloads can carry attempt counts or timestamps.
  • [ ] Typed payload overloads for both, through the configured serializer.
  • [ ] Birth publish failures are observable (logged, counted) and configurable: fail the connection-up (default — presence must be true) or log-and-continue.
  • [ ] Both factories take the client's TimeProvider reality into account: deterministic in tests with a fake clock.
  • [ ] Integration test against a real broker: subscriber sees retained "online" after connect, "offline" after an ungraceful drop (socket kill), and "online" again after the automatic reconnect — the full presence cycle without application code.
  • [ ] Documented as a presence guide page with the retained online/offline pattern.

F7 — API freeze and review ✅

Definition of done

  • [x] Microsoft.CodeAnalysis.PublicApiAnalyzers is enabled on every shipped package with committed PublicAPI.Shipped.txt baselines; an unintended public-surface change fails the build (RS0016/RS0017, verified).
  • [x] One documented API review pass over the whole surface (an adversarial multi-agent review): it found and the freeze fixed four issues — the orphaned MqttApplicationMessage duplicate removed, the internal MqttPacketIdAllocator and the SQLite code-sharing base made internal (the base now composed, so no Microsoft.Data.Sqlite type leaks into the surface), and CancellationToken = default made uniform across ResilientMqttClient.
  • [x] XML documentation for 100% of public members (enforced by GenerateDocumentationFile + warnings-as-errors).
  • [x] A BREAKING-CHANGES.md policy note states the semantic-versioning commitment.

F8 — Broker interop matrix

Mosquitto alone proves too little for a 1.0 interop claim.

Definition of done

  • [x] The Testcontainers integration suite runs the same scenario set against Mosquitto 2, EMQX 5.8, and HiveMQ CE 2024.3: handshake, QoS 0/1/2 round trips, persistent-session resume, retained messages, shared subscriptions, and large (64 KB) payloads. The shared BrokerScenarios helper drives every broker, so a failure names the broker and the scenario. Since 1.1.0 the matrix also runs receive-maximum flow control (F2) and topic aliases (F4) against all three brokers, and a dedicated TLS integration test round-trips a message over a real TLS Mosquitto connection.
  • [x] broker-matrix.yml runs the EMQX/HiveMQ matrix on PRs that touch the source, the integration tests, or shared build inputs (gating before merge), and again on main and on demand; a failure on main opens a tracking issue. The fast ci.yml lane filters Category!=BrokerMatrix, so Mosquitto runs on every PR while the heavy images stay off unrelated PRs.
  • [x] A compatibility table in the docs lists each broker, the version tested, and the verified scenarios.

F9 — Stable toolchain and targets

Definition of done

  • [ ] global.json pins a GA .NET 10 SDK (the repo currently pins a release candidate); allowPrerelease is gone.
  • [ ] Package targets remain net8.0 + net10.0 (both LTS); CI and release pipelines build on the GA SDK.
  • [ ] All dependency pins reviewed and floated to current stable patch versions.

F10 — Soak and stress validation ✅

Definition of done

  • [x] A soak harness (SoakTests, tagged Soak) runs sustained QoS 1 traffic through endless random disconnects against a real broker for a configurable duration (PULSE_SOAK_DURATION, a TimeSpan string; default 30 s smoke / set to 1.00:00:00 — one day — for the full run, since 24:00:00 parses as 24 days): it asserts zero lost messages, that reconnect always recovers, and that the managed heap stays within a bounded tolerance of a post-warmup snapshot. Run on demand; restart the broker container during the run to cover process restarts alongside the network cuts.
  • [x] A chaos test (ChaosIntegrationTests) — random disconnects under sustained load with a persistent session against Mosquitto — runs in CI (the integration suite, every PR), is bounded (~1 s), and passes deterministically: the at-least-once invariant means every sequence number arrives regardless of when the kills land. It surfaced and motivated a real hardening of TcpTransport.DisposeAsync against an abrupt disposal racing an in-flight flush.
  • [x] The benchmark suites build and run on the release candidate; the hot path (codec, publish/subscribe, connection) is unchanged, so the published allocation and latency numbers remain representative. The comparison doc records the release-candidate validation.

Nice-to-have for 1.0 (ship if ready, never block)

N1 — Durable stores package (Pulse.Mqtt.Storage.Sqlite)

ISessionStore + IMessageStore over SQLite so subscriptions, the offline queue, and (with F5) in-flight state survive restarts.

DoD: both contracts implemented including the incremental upsert/remove paths and the peek/remove-head at-least-once contract; crash-mid-flush integration test re-sends rather than loses; corruption test recovers with a clear error; AOT-safe (no reflection-based ORM).

N2 — MessagePack serializer package (Pulse.Mqtt.Serialization.MessagePack) ✅

DoD: IMqttSerializer over MessagePack with correct content type; AOT-compatible (generated resolvers, no dynamic codegen at runtime); round-trip and interop tests; documented in Typed messaging.

Done in 0.7.0: MessagePackMqttSerializer takes MessagePackSerializerOptions, so a source-generated resolver keeps it AOT-safe (the package builds with IsAotCompatible); content type application/x-msgpack. Tests round-trip through the generated resolver, verify the bytes are valid MessagePack (interop via ConvertToJson) and smaller than JSON, and check the error path. Documented in Typed messaging.

N3 — Observability completion ✅

DoD: a receive span (Consumer kind, linked to the producer context when the publish carried trace propagation), a connect span around connection attempts, histogram instruments for publish duration and connect duration, and gauges for offline-queue depth and dropped count; docs updated; overhead measured and negligible with no listener.

Done in 0.6.0: connect/receive spans alongside publish; connect.duration and publish.duration histograms (seconds); offline.queue.depth observable gauge and offline.queue.dropped observable counter, one measurement per live client. Spans and instruments are no-ops with no listener (StartActivity returns null). Documented in the observability guide.

N4 — Trace context propagation ✅

W3C traceparent in user properties, producer-side inject + consumer-side extract, opt-in.

DoD: a publish inside an active span produces a routed handler whose Activity is a child across two clients; off by default; documented.

Done in 0.6.0: ResilientMqttClientOptions.PropagateTraceContext (off by default) injects the active span's traceparent/tracestate onto outbound publishes; the receive span always extracts an incoming traceparent and parents on it, so the routed handler's Activity is a remote child of the producer's span. Verified by tests for inject, off-by-default, and remote parenting.

N5 — MQTTnet migration guide ✅

DoD: a docs page mapping every common MQTTnet pattern (factory/options/builders, handlers, managed client behaviors) to the Pulse equivalent, with before/after code.

Done in 0.7.0: Migrating from MQTTnet maps the factory/options builders, the managed client (reconnect/queue/storage), message and topic-filter builders, the received-message event, last will/birth, request/response, and TLS/WebSocket to their Pulse equivalents, with before/after code, and is linked in the guide sidebar.

N6 — IAsyncEnumerable request streaming ✅

Server-streamed RPC: one request, many correlated responses.

DoD: RequestStreamAsync yields responses until an end-of-stream marker or timeout; responder-side helper publishes the marker; backpressure bounded; tests cover early consumer abandonment.

Done in 0.7.0: RequestStreamAsync (raw + typed) yields correlated responses via a bounded channel (MqttRequestStreamOptions.Capacity) until the end-of-stream user-property marker, the per-item IdleTimeout, or cancellation; OnRequestStreamAsync publishes each yielded item and the marker. Tests cover the happy path, the responder, early abandonment, and the idle timeout.

N7 — WebSocket proxy and header options ✅

DoD: explicit proxy configuration and per-connect headers on WebSocketTransportOptions (today reachable only via ConfigureClient); documented with a reverse-proxy example.

Done in 0.7.0: WebSocketTransportOptions.Proxy and .Headers apply to the opening handshake before ConfigureClient (which still runs last and can override). Tests verify a custom header reaches the server and that an unreachable proxy fails the connection; documented with a reverse-proxy example.


Milestones

VersionContentsExit criterion
0.3.0F1 DISCONNECT, F3 max packet size, F9 GA toolchainA broker can say no politely and the client behaves; builds on GA SDK
0.4.0F2 receive maximum, F4 topic aliasesNegotiated-limit compliance complete
0.5.0F11 presence (will + birth, static + factory), F5 session redelivery, F6 enhanced authPersistent sessions and presence are honest end to end
0.6.0F8 broker matrix, N3/N4Interop proven beyond Mosquitto
0.7.0N1 SQLite stores, N2 MessagePack, N5 migration guide, N6 request streaming, N7 WebSocket proxyNice-to-haves shipped
1.0.0-rc.1F7 API freeze, F10 soak/stress, docs completePublic API locked; release candidate published
1.0.0rc.1 promoted, no new featuresAll F-items checked; quality gates green

Every required feature (F1–F11) and every nice-to-have (N1–N7) is implemented, tested, and shipped. The public API is frozen and analyzer-enforced; the resilience guarantees are validated under chaos against real brokers; the interop matrix covers Mosquitto, EMQX, and HiveMQ. 1.0.0 is released.

Rules of the road, unchanged from the start of the project: every step lands as its own merged PR with tests; quality, performance, memory, and swap-point interchangeability are non-negotiable; anything that would break the frozen API after 1.0.0-rc.1 waits for 2.0.

The post-1.0 horizon (not planned here): MQTT over QUIC, broker-side feature probes, and more protocol-specific tooling.

Last updated:

Released under the MIT License.