All notable changes to this project will be documented in this file.

[0.11.2] - 2026-08-02

Added

  • MqttX.Packet.ReasonCodes — named constants for the MQTT 5.0 reason codes and MQTT 3.1.1 CONNACK return codes this library sends, plus an is_error_code/1 guard for the §2.4 rule that 0x80 and above means failure. Handler callbacks can return {:error, ReasonCodes.bad_authentication_method(), state} instead of a bare 0x8C. It covers the codes the library itself uses, not the full spec table.

Security

Findings from a multi-agent security review of the whole repository at a8f28fe. Each fix carries a regression test that was confirmed to fail against the previous code.

  • A Will message bypassed publish authorization. An ordinary PUBLISH stores its retained entry only after handle_publish/4 accepts it, but both Will paths — immediate (MqttX.Transport.Handler) and delayed (MqttX.Server.WillDelay) — wrote to the retained store first and then discarded the handler's answer. A peer that got past handle_connect could therefore plant, or with an empty payload delete, a retained message on any topic its ACL forbids; deliver_retained_messages/2 later handed it to subscribers without consulting the handler again. Both paths now publish through the handler first and store only on {:ok, _}. A Will's topic and payload never reach handle_connect, so handle_publish/4 is the only callback that can authorize them — brokers relying on it for per-topic authorization were affected.
  • A broker could choose which session record the client overwrote. The client adopted the CONNACK assigned_client_identifier unconditionally and used the resulting client_id as the session_store key, so a malicious or MITM'd broker could rename the connection to another client's id and clobber that client's persisted session in the shared table — the victim would then replay the attacker-influenced subscriptions and in-flight publishes against its own broker. The store is now keyed by an immutable session_key captured in init/1, and the assigned identifier is honoured only when the client actually sent a zero-length one, per §3.2.2.3.7 (a broker that sends one anyway is ignored with a warning). Affects session_store + clean_session: false deployments sharing a session table.
  • Log-record forgery via client_id. The keepalive-timeout log line interpolated the client-supplied identifier raw; the codec permits CR, LF and ANSI escapes, so a client could forge additional log records or corrupt an operator's console. It now uses inspect/1, as every other log site in that module already did.

[0.11.1] - 2026-07-31

Fixed

  • README links render correctly on hex.pm's package page. The page shows the README from the package tarball and rewrites relative links to the raw file server (repo.hex.pm/preview/...), which serves guide pages as plain text. All relative documentation links in the README are now absolute hexdocs.pm URLs, which work on hexdocs, GitHub, and hex.pm alike; a documentation test resolves each one back to its source file and verifies the page and heading anchor exist.

[0.11.0] - 2026-07-31

A security and correctness release. Every Critical and High finding from a full-codebase audit is fixed, several features that were silently broken now work (the Ranch transport, inbound topic aliases, reconnect resubscription, dead-link detection), and two long-requested features land: use MqttX and HTTP CONNECT proxy support.

Upgrading: some defaults change deliberately — read Changed first. The most likely surprise is TLS certificate verification, which is now on by default.

Changed (breaking defaults)

  • Client TLS verifies certificates by default. :ssl and :wss use verify: :verify_peer with the OS trust store (:public_key.cacerts_get/0), SNI, HTTPS-style hostname checking, and TLS 1.2/1.3 only. Your :ssl_opts are merged over that baseline, so verify: :verify_none still works — explicitly, and with a logged warning. Setups that silently relied on no verification will now fail until certificates are configured; that is the point.
  • Server TLS baseline. When a TLS transport is selected, the ThousandIsland and Ranch adapters inject versions: [:"tlsv1.3", :"tlsv1.2"], secure_renegotiate: true, and honor_cipher_order: true beneath your :transport_options.
  • Server max_packet_size defaults to 1 MiB (was unlimited) and is enforced on the declared remaining length before the body is buffered, closing a pre-auth memory-exhaustion vector. max_packet_size: :infinity restores the old behavior.
  • Client max_packet_size defaults to 1 MiB, applying the same check to data from the broker.
  • Socket liveness no longer depends on Keep Alive alone. ThousandIsland's 60 s read_timeout was disconnecting spec-compliant clients whose keepalive exceeded it, so it now defaults to :infinity; in its place the protocol handler enforces :max_idle_timeout (default 15 min, :infinity disables), which resets on any inbound data and therefore also covers clients that negotiate keep_alive: 0. A separate :connect_timeout (default 10 s) closes sockets that never complete CONNECT.
  • Ranch transport selection renamed to :ranch_transport (:ranch_tcp | :ranch_ssl). The old :transport key collided with MqttX.Server's own :transport option (the adapter module) — a collision that made the documented Ranch setup fail at startup. :transport is still honored when it holds a valid ranch atom.
  • The client discards in-flight QoS 1/2 state when the broker starts a fresh session (session_present: false, MQTT-3.2.2-4), with a warning, instead of retaining state it can never deliver.
  • MqttX.Client.connect/1 no longer waits for CONNACK. The handshake is now event-driven, so connect/1 returns before the session is live and a subscribe/publish issued immediately after it returns {:error, :not_connected}. Previously the blocking handshake made such calls queue in the mailbox until CONNACK, so they appeared to succeed. Either act on the handler's :connected event, or pass the new await_connect: true option to block until the first attempt resolves — that form returns {:error, reason} (e.g. :econnrefused or {:connack_error, code, info}) and stops the client if the attempt failed. The asynchronous default is deliberate: a client can now be started before its broker is reachable and keep retrying with backoff, which a blocking connect cannot do.
  • Malformed packets close the client connection (§4.13) instead of being logged and left at the head of the buffer, which wedged all further decoding and grew the buffer without bound. Handlers see (:disconnected, {:protocol_error, reason}) and the client reconnects.

Added

  • use MqttX — module-based client (GitHub #1). Define a client as one module with init/1, handle_message/4, handle_connected/2, handle_disconnected/2, handle_publish_error/4, and handle_info/2 (all defaulted), add it to a supervision tree as {MyClient, connect_opts}, and use the injected publish/subscribe/… helpers — including from inside callbacks, which run in the module's own process and so cannot deadlock the connection. See MqttX.SimpleClient.
  • HTTP CONNECT proxy support (GitHub #2). Pass proxy: [host: "proxy.corp", port: 3128, auth: {"user", "pass"}] to tunnel any transport (:tcp/:ssl/:ws/:wss) through a forward proxy (RFC 9110 §9.3.6). TLS and the WebSocket upgrade layer over the established tunnel, so certificate verification and SNI target the broker, not the proxy. Basic auth is supported; a non-200 response fails the attempt with {:proxy, {:proxy_status, code}} and backs off normally.
  • Automatic resubscription. Granted subscriptions are recorded on SUBACK and replayed after any reconnect reporting session_present: false. Previously they were never tracked, so a transient disconnect left the client connected but deaf. Tracked subscriptions persist via :session_store.
  • Prompt in-flight resend on session resumption (§4.4/§4.6). On CONNACK with session_present: true, unacknowledged QoS 1/2 PUBLISHes (dup=1) and PUBRELs are resent immediately in original submission order — not up to retry_interval later in arbitrary map order.
  • MqttX.Packet — public façade for the codec (encode/2, decode/2, encode_iodata/2, declared_length/1). The landing-page docs referenced this module before it existed.
  • MqttX.Packet.Codec.declared_length/1 — the wire size a buffered packet declares, readable from its first bytes; used for early size enforcement.
  • New handler event (:publish_error, {topic, packet_id, reason_code}) and telemetry event [:mqttx, :client, :publish, :exception] for broker-rejected QoS 1/2 publishes.
  • Server options :max_idle_timeout, :connect_timeout, :max_retained_messages, ThousandIsland :read_timeout, and WebSocket :max_frame_size; client options :max_packet_size and :await_connect.
  • :ssl and :public_key added to extra_applications, so TLS transports work inside Mix releases without the host app listing them.

Fixed — remote crashes and DoS

  • Codec crashes reachable from the network. CONNACK / v5 PUBACK / PUBREC / PUBCOMP / PUBREL / DISCONNECT / AUTH packets with trailing bytes after their properties raised CaseClauseError; v3/v4 acks with bytes after the packet id raised FunctionClauseError. Both now return {:error, :malformed_packet}. Truncated content inside a complete packet (property-length overrun, truncated UTF-8) was misreported as :incomplete, wedging client and server buffers — now :malformed_packet.
  • A truncated varint inside a property slice crashed the decoder. Varint.decode/1 returns a bare :incomplete, and the Subscription Identifier decoder's with had no else, so that atom escaped into Properties.decode/2 and raised CaseClauseError. Eight bytes were enough to kill a connection process, on client and server alike. Found by the new property-based fuzzing (1 failure in ~80 000 bit-flipped packets), now returns {:error, :malformed_packet}; the property-decode boundary also rejects any unexpected shape defensively rather than raising.
  • Unauthenticated peers could suppress another client's Last Will. WillDelay.cancel/1 and SessionExpiry.cancel/1 ran at the top of CONNECT, before authorization; they now run only after the handler accepts the connection, alongside session takeover.
  • Unbounded buffering from a hostile broker. The client now enforces a declared-length packet cap, and the WebSocket client caps a single frame's declared payload (a 64-bit length field previously had no ceiling).
  • Proxy hardening. CR/LF, whitespace, and NUL in :host are rejected before they can inject headers into the CONNECT request, and a non-numeric proxy status no longer raises inside the connection process.
  • Retained storage is bounded by :max_retained_messages (default 100 000 topics) and written only after the handler accepts the publish, so unauthorized publishers cannot fill it. A delayed Will's retained write respects the same cap.
  • The client honors the Topic Alias Maximum it advertised, rejecting larger aliases from the broker.
  • WillDelay / SessionExpiry timers are keyed by {listener, client_id}, matching session takeover's scope, so two brokers in one VM cannot cancel each other's timers.

Fixed — client

  • The blocking CONNECT handshake is gone. The client no longer sits in a bare receive between CONNECT and CONNACK: the handshake runs through the normal GenServer loop in a :connecting state. Calls answer immediately during a (re)connect; one total handshake deadline applies, so a broker trickling AUTH packets can no longer hold the client open indefinitely; and a CONNACK split across TCP segments now reassembles correctly. The deadline is generation-tagged so a late timeout cannot abort a later handshake.
  • Dead-link detection works. The PINGRESP deadline was cancelled and re-armed by every keepalive tick (1.0×K tick vs 1.5×K deadline), so it could never fire and a black-holed connection was never detected. An unanswered PINGREQ's deadline now stands, and stale pingresp/keepalive/retry timers are cancelled on every teardown path so they cannot kill the next connection.
  • Supervised clients can be disconnected. Connection ships a restart: :transient child spec, so disconnect/2's normal stop is no longer resurrected by the DynamicSupervisor into an immediate reconnect. Calling disconnect/2 while offline no longer crashes on :gen_tcp.send(nil, _).
  • Non-retryable CONNACK rejections stop the client rather than reconnecting forever: bad credentials (0x86/0x04), not authorized (0x87/0x05), invalid client id (0x85/0x02), unsupported version (0x84/0x01), banned (0x8A), bad auth method (0x8C). Handlers receive (:disconnected, {:connack_error, code, info}) and the process exits normally; transient failures still retry with backoff.
  • Inbound topic aliases never resolved — a type mismatch delivered every aliased PUBLISH with topic "". Aliased messages now carry the real topic, alias maps reset per connection in both directions, and an unknown alias is a protocol error (§3.3.2.3.4).
  • QoS retries no longer replay stale topic-alias state across reconnects: pending entries store the original topic with no alias property, so a retry cannot trigger a broker protocol error or rebind an alias to another topic.
  • PUBACK/PUBREC error reason codes (>= 0x80) are surfaced — warning log, :publish_error handler event, exception telemetry — instead of counted as success, and an error PUBREC no longer triggers a spec-violating PUBREL.
  • A PUBREL for an id the client no longer tracks is answered with PUBCOMP (0x92 in v5) instead of ignored, so a lost PUBCOMP no longer leaves the broker retrying forever.
  • Inbound QoS 2 messages are bounded by the client's advertised Receive Maximum (DISCONNECT 0x93 if the broker exceeds it).
  • Packet-id allocation skips ids held by in-flight SUBSCRIBE/UNSUBSCRIBE (shared id space, §2.2.1) and returns {:error, :packet_ids_exhausted} rather than reusing a live id; callers blocked in subscribe get {:error, :not_connected} immediately on disconnect instead of a timeout.
  • The client traps exits and implements terminate/2, so supervisor/VM shutdown sends a clean DISCONNECT (no spurious will) and saves the session.
  • Session-store failures (init/load/save) are logged instead of silently disabling requested persistence; custom ETSStore tables are created via the supervised ETSOwner (they used to be owned by the connection process and died in the very crash they existed to survive).
  • The WebSocket client honors control frames: Pings get matching Pongs (proxies liveness-check this way), a Close triggers a proper reply and teardown, and fragmentation state resets per connection.
  • Handler callbacks are isolated — a raising handle_mqtt_event/3 is logged instead of taking down the connection (which could poison-loop on QoS 1 redelivery) — and Code.ensure_loaded? runs before the callback check, so a not-yet-loaded handler module in dev/iex no longer discards every event.
  • publish(client, topic, payload, qos: 3) returns {:error, :invalid_qos} instead of sending a malformed packet and crashing the connection.
  • MqttX.Client.connect/1 returns {:error, {:missing_option, :host | :client_id}} instead of raising KeyError; its @spec is widened to GenServer.on_start() | {:error, term()}.
  • Reconnect backoff state was discarded on several failure paths (the timer fired but the delay never grew); :jitter is clamped to [0, 0.9], since values >= 1 could produce a negative delay and crash Process.send_after.

Fixed — broker / server

  • The Ranch adapter was non-functional. :ranch.handshake/1 ran inside GenServer.init/1, deadlocking ranch's connection supervisor on the first inbound connection; it now uses the documented :proc_lib.spawn_link + enter_loop pattern. Also fixed there: TLS ({:ssl, ...}) messages were handed to the user handler instead of the MQTT decoder, and close/stop paths skipped handle_close, so wills, handle_disconnect, and session expiry never ran on Ranch.
  • Session takeover (§3.1.4-3): a CONNECT reusing a live client_id now disconnects the incumbent (0x8E) and takes over, via a per-listener registry. Two connections could previously share a client_id indefinitely.
  • Session expiry is supervised and cancellable — the Task.start + Process.sleep timers were uncancellable and fired even for clients that had reconnected, wiping live sessions. Replaced by MqttX.Server.SessionExpiry, mirroring WillDelay; a fresh CONNECT for the same client_id cancels the pending expiry.
  • A second CONNECT on a live connection is a protocol error (0x82, §3.1.0-2) instead of re-running auth, sending a second CONNACK, and leaking a keepalive timer that would later tear down the session and spuriously publish the will.
  • Client Receive Maximum is enforced on outbound QoS > 0 (§3.3.4): sends beyond the advertised window queue and drain as acks arrive, instead of flooding constrained clients into a DISCONNECT 0x93.
  • Retained deliveries at QoS > 0 are tracked in flight, routed through the normal send path — a QoS 2 retained delivery now completes its PUBREC/PUBREL/PUBCOMP handshake (the PUBREC used to be ignored and the flow hung forever) and QoS 1 retained deliveries retransmit on loss.
  • Shared subscriptions are keyed by the (ShareName, TopicFilter) pair (§4.8.2). One group name across several filters previously collapsed into a single group, and the second filter's traffic reached nobody.
  • Idle timeouts publish the will and start session expiry like any other abnormal close.
  • WebSocket stop paths deliver queued frames: rejection CONNACKs and DISCONNECT packets are sent before the close frame instead of being discarded, so WS clients can distinguish auth failure from a network blip. The rate-limited upgrade path returns its 1008 close code correctly.
  • handle_disconnect/2 fires exactly once per connection (it previously ran twice, sometimes three times, across the inline and terminate paths).
  • v5 DISCONNECT properties are honored: a revised session_expiry_interval (e.g. 0 for "drop my session now") takes effect, and a 0 → non-zero revision is rejected as a Protocol Error (§3.14.2.2.2).
  • Will Delay honors session end (§3.1.2.5): with session expiry 0 the will publishes immediately on abnormal disconnect, otherwise at min(delay, session expiry).
  • Enhanced re-authentication (§4.12.1) answers with AUTH 0x00 on success and DISCONNECT on failure — never a second CONNACK.
  • PUBREC with an error reason aborts the QoS 2 flow without PUBREL; an unknown packet id is answered with PUBREL 0x92 (v5).
  • retain_handling: 1 is honored (retained messages only for new subscriptions), and retained delivery is capped at the granted QoS rather than the requested one.
  • v3.1.1 CONNECT with a zero-byte ClientID and CleanSession=0 is rejected with 0x02 (MQTT-3.1.3-8).
  • Router.match/2,3 delivers at the maximum QoS across a client's overlapping subscriptions (§3.3.4) instead of an arbitrary one; only match_and_advance/3 advances shared-group round-robin, now documented.

Removed

  • MqttX.Packet.Types — 93 constant-accessor functions (packet type numbers, reason codes, property identifiers) with no callers anywhere in the library, tests, or documentation; the codec carries its own constants. Codec users are unaffected: the public entry points are MqttX.Packet / MqttX.Packet.Codec, which speak maps and atoms — the numeric wire constants were never part of the API surface.

Fixed — codec

  • Malformed SUBACK/UNSUBACK reason bytes fail the whole decode; previously the packet decoded "successfully" with the error buried in the acks list. Empty SUBACK (and v5 UNSUBACK) ack lists are rejected.
  • Strings and binaries over 65 535 bytes return {:error, :string_too_long} on encode instead of silently wrapping the 16-bit length prefix and corrupting the wire (topics, client_id, credentials, will data, and every string/binary property). Topic.validate/1 applies the same cap to list-form topics.
  • Packet ids are validated on encode — {:error, :missing_packet_id} for absent or zero ids on QoS > 0 PUBLISH, acks, and (un)subscribe packets, which previously encoded as 0x0000 and had to be rejected by every receiver.
  • PUBLISH encode validates QoS (0-2) and the topic (no wildcards, non-empty unless a topic alias is set); SUBSCRIBE/UNSUBSCRIBE reject empty topic lists; CONNECT rejects an out-of-range keepalive.
  • Property values are range-checked on encode (32/16-bit integers, varint subscription identifiers), and unknown or mistyped properties return {:error, {:invalid_property, name}} instead of vanishing from the wire.
  • UTF-8 validity (no U+0000, no surrogates) is enforced on encode, matching decode. The Will payload and password are correctly treated as binary data rather than UTF-8 strings.
  • v3.1.1 SUBSCRIBE: v5-only subscription option bits (nl/rap/rh) are rejected on decode (§3.8.3-4) and never emitted on encode.
  • MqttX.Payload.JSON is always defined; on runtimes without the native JSON module it returns {:error, :json_not_available} instead of the module simply not existing.

Tooling, docs, and tests

  • CI matrix extended to Elixir 1.20 / OTP 29 (released since 0.10.0): the test job now covers 1.18/27 (the mix.exs floor), 1.19/28, 1.20/28, and 1.20/29; the dialyzer and publish jobs run on 1.20/29. The README's quoted range is pinned to the workflow file by a test.
  • All compiler warnings clearedmix compile --warnings-as-errors, which CI runs and which gates the release job, now passes. Twelve were unreachable atom_to_type/1 clauses; three were unpinned bitstring size variables.
  • Dialyzer configured (plt_add_apps for the optional transports); stream_data added for property-based tests. mix dialyzer now passes cleanly, which matters because the release job declares needs: [test, dialyzer] — two errors were failing it:
    • MqttX.Server.WillDelay's ctx() type omitted :max_retained_messages, a key the module itself reads when republishing a delayed Will. The type and the code had disagreed since the retained-message cap was added.
    • The Will delay passed to WillDelay.schedule/4 was only inferred as integer(), not the non_neg_integer() the contract requires. The values do come from 32-bit unsigned properties, but nothing enforced it; a float or negative reaching Process.send_after/3 would crash the supervised WillDelay process and silently drop the Will. The invariant is now enforced at the boundary by a guarded will_delay_ms/1.
  • Removed a defensive _other clause in MqttX.Packet.Properties.decode/2 that dialyzer proved unreachable: it guarded against a property decoder leaking a bare atom, which is now prevented at its source by the explicit else on the Varint.decode/1 with. decode_utf8/1 and decode_binary/1 were confirmed to return proper {:error, _} tuples.
  • Dependencies refreshed: thousand_island 1.5.0, bandit 1.12.4, telemetry 1.4.2, ranch 2.2.1, protox 2.0.9, ex_doc 0.40.3.
  • websock_adapter requirement widened to ~> 0.5 or ~> 0.6. 0.6.0 makes no API change — its only change is a default max_frame_size of 10 MB (previously :infinity) — but the old ~> 0.5 requirement excluded it and would have blocked installation for any app already on 0.6. The WebSocket transport gained a :max_frame_size option so this limit can be set explicitly rather than varying with the resolved adapter version.
  • Documentation overhaul. Installation and Quick Start moved to the top of the README, with the cellular-IoT analysis extracted to a new Why MQTT for IoT guide. use MqttX, the proxy option, the secure-TLS default, the :publish_error event, and the new server options are documented across the README, guides, AGENTS.md, and moduledocs. Telemetry docs now state the real topic shape (inbound events carry decoded segment lists, not binaries) and list the new exception event.
  • Documentation examples are now tested (documentation_test.exs): every fenced elixir block in the README, guides, and AGENTS.md must parse, every MqttX.* function it calls must exist at that arity, transport: values must name real modules, and the declared {:mqttx, "~> x.y"} version must match mix.exs. This caught a landing-page example that raised UndefinedFunctionError and a guide pinning an outdated version.
  • Fixed the websock_adapter requirement in the install instructions. The README, AGENTS.md, and the getting-started and server guides all told users to add {:websock_adapter, "~> 0.5"} — the very requirement that was widened to "~> 0.5 or ~> 0.6" in mix.exs, so anyone copying the snippet re-created the resolution failure the widening was meant to fix. All four now match, and a test pins every documented dependency requirement to mix.exs.
  • guides/server.md now documents every server callback. handle_puback/2 and handle_auth/3 were missing from the "Callback Summary" table, so a broker author could not discover MQTT 5.0 enhanced authentication or QoS 1 delivery confirmation from the guide. The table is now pinned to MqttX.Server.behaviour_info(:callbacks) by a test.
  • The clean-session limitation is no longer buried. The note that session_present is always false sat as an H3 inside "MQTT 5.0 Protocol Features", though it applies to every protocol version; it is now a top-level Session State section next to Session Expiry.
  • Duplicated examples consolidated to one canonical copy. The handler example, the use MqttX example, and the codec benchmark table each existed in three to five places and had already drifted apart. The guides are now canonical for how-to material and the README links to them; the copies that must stay (AGENTS.md, which is meant to be self-contained, and moduledocs, which must stand alone on their hexdocs page) are pinned by tests that fail when the benchmark figures, the handler event list, or the use MqttX callback set diverge.
  • New regression suites: malformed-packet decoding, Ranch adapter integration (previously zero Ranch tests), inbound topic aliases, resubscribe on reconnect, in-flight resend/discard, the non-blocking connect FSM, declared-length enforcement, SUBACK/UNSUBACK validation, encode length guards, multi-filter shared subscriptions, session takeover, session-expiry cancellation, DISCONNECT expiry revision, retain_handling: 1, the outbound Receive Maximum queue, and idle/handshake timeouts.
  • Property-based codec tests: round-trips for PUBLISH/SUBSCRIBE/acks/CONNECT/ properties/varints, plus "decode never raises" over arbitrary bytes and bit-flipped valid packets.
  • Client unit tests use guaranteed-closed ephemeral ports instead of assuming nothing listens on localhost:1883.

[0.10.0] - 2026-05-07

Spec-compliance and correctness sweep against MQTT 5.0 (OASIS), plus server/ client robustness fixes. EMQX interop suite (49 tests against a live broker) remains 100% green. No changes to the function API surface, but note the behavioral/wire default change below: the default protocol_version flipped from 4 to 5, which is observable by brokers and dependents.

Changed (potentially breaking at the wire level — stricter spec compliance)

  • Default protocol_version is now 5 (was 4). Library is marketed "MQTT 5.0" and v5 features (topic aliases, AUTH, properties, reason codes) were silently dropped under the v3.1.1 default. MqttX.Client.connect(protocol_version: 4) to opt in to v3.1.1.
  • Server now rejects unsupported protocol versions with CONNACK 0x84 (v5) / 0x01 (v3.x). Default allowlist [3, 4, 5] configurable via :supported_versions in transport_opts.

Fixed (codec — MqttX.Packet.Codec / MqttX.Packet.Properties)

  • PUBLISH with QoS=3 rejected as Malformed Packet (§3.3.1.2)
  • PUBLISH with DUP=1 + QoS=0 rejected (§3.3.1.1)
  • CONNECT reserved bit must be 0 (§3.1.2.3); non-zero fixed-header flags rejected (§3.1.1)
  • Will Flag=0 with non-zero Will QoS or Will Retain rejected (§3.1.2.6/7)
  • Will QoS=3 rejected (§3.1.2.6)
  • v3.x username flag=0 with password flag=1 rejected (§3.1.2.9)
  • Empty SUBSCRIBE / UNSUBSCRIBE payload rejected as Protocol Error (§3.8.3 / §3.10.3)
  • Subscription Options reserved bits non-zero, QoS=3, RH=3 rejected (§3.8.3.1)
  • DISCONNECT and AUTH 1-byte forms (reason code only, no property length) accepted per §3.14.2.2.1 / §3.15.2.2.1
  • UTF-8 strings now reject U+0000 (null) and U+D800–U+DFFF (surrogates) per §1.5.4
  • Malformed CONNECT no longer crashes the codec with MatchError; surfaces :malformed_packet
  • Invalid SUBACK/UNSUBACK reason bytes return :malformed_packet instead of crashing
  • Properties — duplicate non-User-Property properties rejected as Protocol Error (§2.2.2.2)
  • Property value-0 rejection: Subscription Identifier (§3.8.2.1.2), Receive Maximum (§3.1.2.11.3), Maximum Packet Size (§3.1.2.11.4)
  • Boolean properties (Payload Format Indicator, Request Problem Information, Retain Available, etc.) reject non-0/1 byte values
  • Maximum QoS rejects values > 2
  • User-Property accumulation switched from O(n²) ++ [val] to prepend + reverse-once
  • Subscription-Identifier list switched from O(n²) ++ [val] to prepend + reverse-once

Fixed (topic — MqttX.Topic)

  • +/# filters at the first level no longer match $SYS/...-style topics (§4.7.2)
  • Topic length capped at 65535 bytes (§1.5.4)
  • Shared subscription $share/<group>/... rejects + or # in ShareName (§4.8.2)
  • flatten/1 switched from O(n²) binary concat to iolist + Enum.intersperse

Fixed (server — MqttX.Transport.Handler / MqttX.Server.Router / MqttX.Server.RateLimiter)

  • Outgoing QoS 1 retransmission on the server (§4.4). Outbound QoS 1 PUBLISHes are now tracked in pending_qos1_tx; the existing periodic retry timer re-sends with DUP=true after qos2_retry_interval ms, dropping after qos2_max_retries attempts. PUBACK arrival clears the entry. Previously QoS 1 outbound was fire-and-forget — a lost PUBACK silently dropped the message.

  • Receive Maximum applies to QoS 1 inbound as well as QoS 2 (§3.1.2.11.3). Previously the limit was only checked for QoS 2; a client could fill the flow-control window with un-PUBCOMP'd QoS 2 messages and then push QoS 1 publishes through unbounded. The handler now responds to a QoS 1 PUBLISH exceeding the limit with PUBACK reason 0x93 (Receive Maximum exceeded). QoS 2 excess continues to receive PUBREC 0x93 (spec permits either per-message ack or DISCONNECT).

  • Critical: retained-publish packet IDs now come from next_packet_id instead of :rand.uniform/1 — was colliding with the QoS sequence allocator and breaking ack matching

  • Will message published exactly once on keepalive timeout / handle_close / handle_error (was double-publishing through two paths)

  • DISCONNECT reason code 0x04 publishes the Will; other reason codes suppress it (§3.14.4) — previously the reason code was ignored

  • Empty topic with un-mapped Topic Alias triggers DISCONNECT 0x94 (§3.3.4) — previously dispatched an empty-topic PUBLISH to the user handler

  • Outbound oversize-drop rolls back the packet_id allocation (was leaking)

  • Duplicate PUBREC after PUBREL no longer re-sends PUBREL (§4.3.3 fig 4.4)

  • AUTH-before-CONNECT now sends DISCONNECT 0x82 instead of leaving the handler in a broken state (CONNACK with protocol_version: nil)

  • Will Delay Interval timers (§3.1.3.2.2) now owned by a supervised MqttX.Server.WillDelay GenServer under the application supervisor; cancelled on same-client_id reconnect

  • Rate-limiter ETS table now created with read_concurrency: true

Fixed (client — MqttX.Client.Connection)

  • Critical: retry-loop reducer arity bug fixed — was crashing with MatchError once both {:rx, _} and {:tx, _} pending_acks entries coexisted on the same connection
  • keepalive == 0 correctly disables the keepalive timer (was scheduling a zero-millisecond tight loop)
  • PINGRESP timeout: client now arms a deadline at keepalive*1500ms on every PINGREQ; if PINGRESP doesn't arrive, the socket is torn down and reconnect is scheduled. Half-dead brokers no longer require TCP teardown to detect.
  • session_present from CONNACK now surfaced to the handler in the :connected event; warns on MQTT-3.2.2-2 violation (clean_session=true but session_present=true)
  • Server-initiated DISCONNECT now closes the socket immediately and schedules reconnect (was waiting for :tcp_closed to land separately)
  • AUTH continuation in wait_for_connack re-arms set_socket_active/1 so multi-step AUTH doesn't hang after the first packet
  • AUTH packet property names corrected: :auth_method / :auth_data:authentication_method / :authentication_data
  • next_packet_id skips IDs currently in pending_acks (§2.2.1)
  • schedule_reconnect cancels any existing reconnect timer (no more stacked timers when several disconnect events fire)
  • set_socket_active guards against nil socket (server-disconnect race)
  • Pending SUBACK/UNSUBACK callers monitored via Process.monitor; entry dropped on :DOWN (was leaking after GenServer.call timeouts)

Fixed (WebSocket client — MqttX.Client.WebSocket)

  • Sec-WebSocket-Accept now validated as Base64(SHA1(client_key + magic GUID)) per RFC 6455 §4.1; status line strictly matched as HTTP/1.x 101 …
  • Sec-WebSocket-Protocol echo checked (warn-only on missing — common with MQTT brokers that nonetheless speak it correctly)
  • FIN-bit fragmentation: opaque frag_state threaded across decode_frames/2 calls so multi-frame messages split across TCP reads reassemble correctly
  • Frame masking switched from byte-by-byte recursion to :crypto.exor/2 against a pre-padded mask buffer (orders-of-magnitude faster on large payloads)

Added

  • MqttX.Session.ETSOwner — a long-lived owner of the default :mqttx_sessions ETS table under the application supervisor. Previously the table was owned by whichever client first called MqttX.Session.ETSStore.init/1, so all sessions were lost when that process exited. The table is now created with read_concurrency: true, write_concurrency: true.
  • MqttX.Server.WillDelay — supervised GenServer that owns Will Delay Interval timers across connection lifecycles, with per-client_id cancellation.
  • :supported_versions server transport option (default [3, 4, 5]).

Documentation

  • AGENTS.md — usage guide for AI coding assistants integrating MqttX into projects: mental model (client vs broker), transport selection, idiomatic patterns (receive-on-client, broker↔PubSub bridge, persistent sessions, custom auth), and a curated list of mistakes commonly made (publishing wildcards, confusing handle_publish with handle_mqtt_event, random client_id per connect, default-to-QoS-2, $SYS exclusion). Shipped in the hex package and rendered on hexdocs.
  • CONTRIBUTING.md — repo orientation for contributors: layout, test commands, known test-environment couplings, and the deferred TODO carried over from the v0.9.0 spec sweep. CLAUDE.md is now a symlink to AGENTS.md for tool compatibility.
  • README "Common Patterns" section with worked examples for receiving messages on the client (handle_mqtt_event/3), broadcasting from the server via handle_info/2, and resuming MQTT 5.0 sessions with session_expiry_interval.
  • README "Common Pitfalls" section covering session-store / clean_session interaction, server keepalive override, max-packet-size enforcement, publish-vs-subscribe wildcard rules, and $SYS topic exclusion.

[0.9.0] - 2026-03-30

Added

  • Server keepalive override (MQTT 5.0): Configurable server_keep_alive in transport_opts — server sends keepalive override in CONNACK and uses it for the keepalive timer when protocol version >= 5
  • handle_connect/4 callback (optional): New handle_connect(client_id, credentials, connect_info, state) callback that receives connection metadata (protocol_version, keep_alive) separately from credentials. Existing handle_connect/3 continues to work unchanged — handle_connect/4 takes precedence when defined

[0.8.0] - 2026-03-11

Added

  • Complete MQTT 5.0 Client Compliance: Closed all remaining client-side spec gaps
    • server_keep_alive: Client applies server's keepalive override from CONNACK (§3.2.2.3.14)
    • assigned_client_identifier: Client adopts server-assigned client ID from CONNACK (§3.2.2.3.7)
    • maximum_packet_size: Client enforces server's maximum packet size from CONNACK; oversized outgoing packets return {:error, :packet_too_large} (§3.2.2.3.6)
    • server_reference: Client parses and logs server redirect on CONNACK rejection and server DISCONNECT (§3.2.2.3.18)
    • Enhanced AUTH: Client handles multi-step AUTH exchange during and after CONNECT via handle_auth/3 callback (§4.12)
  • EMQX Cloud Interop: 49 automated interop tests against EMQX Cloud broker
  • SUBACK Reason Code Checking: subscribe/3 now returns {:ok, [granted_qos]} or {:error, {:subscription_refused, acks}} based on actual SUBACK response
  • Outgoing Topic Aliases (MQTT 5.0): Client automatically assigns and reuses topic aliases for repeated publish topics, reducing bandwidth
  • DISCONNECT with Reason Code: disconnect/2 accepts :reason_code and :properties options for MQTT 5.0 graceful disconnect
  • WebSocket Client Transport: Connect to brokers over WebSocket with transport: :ws or transport: :wss (RFC 6455 binary framing)
  • Reason String Surfacing: Server reason strings from SUBACK, UNSUBACK, PUBACK, DISCONNECT are logged automatically

Fixed

  • Formatting issues across multiple files (CI compliance)
  • Dialyzer callback_type_mismatch in WebSocket transport close/1 (now returns :ok per behaviour spec)
  • WebSocket frame decoder byte-alignment bug in decode_one_frame (mask_bit extraction)

[0.7.0] - 2026-03-05

Added

  • Full MQTT 3.1/3.1.1/5.0 Compliance: Closed all remaining spec compliance gaps
    • Pre-CONNECT packet rejection: Non-CONNECT/AUTH packets before CONNECT now trigger DISCONNECT 0x82 (Protocol Error) per spec
    • Topic alias validation: Incoming topic aliases are validated against topic_alias_maximum; out-of-range aliases trigger DISCONNECT 0x94
    • MQTT 5.0 property forwarding: Outgoing PUBLISH packets now forward properties (user_properties, content_type, correlation_data, etc.) from handler callbacks
    • CONNACK capability properties: Server advertises retain_available, wildcard_subscription_available, and subscription_identifier_available in CONNACK for MQTT 5.0 connections
    • retain_handling support: Subscription option retain_handling: 2 suppresses retained message delivery on subscribe
    • no_local support: Router.match/3 and Router.match_and_advance/3 accept optional publisher parameter to filter out subscriptions with no_local: true
    • Client server DISCONNECT handling: Client now handles server-initiated DISCONNECT packets, notifying the handler with {:server_disconnect, reason_code}
  • QoS 2 Retransmission & DUP Handling (Server): Periodic retry timer re-sends PUBREC/PUBLISH(dup)/PUBREL for stale in-flight QoS 2 messages; drops after configurable max retries. DUP incoming PUBLISH re-sends PUBREC without re-delivering.
  • Topic Aliases (MQTT 5.0 Server): Incoming PUBLISH with topic_alias property resolved automatically. Server advertises topic_alias_maximum in CONNACK. Alias-only publishes (empty topic) look up stored mapping.
  • Flow Control / Receive Maximum (MQTT 5.0 Server): Server enforces receive_maximum for incoming QoS 2 messages. Excess publishes receive PUBREC with reason code 0x93 (Receive Maximum exceeded). Server advertises receive_maximum in CONNACK.
  • Maximum Packet Size (MQTT 5.0 Server): Configurable max_packet_size option. Oversized incoming packets trigger DISCONNECT with reason code 0x95 (Packet too large). Outgoing publishes exceeding client's maximum_packet_size are silently dropped. Server advertises maximum_packet_size in CONNACK when configured.
  • WebSocket Transport: MQTT over WebSocket via Bandit, supporting all MQTT protocol features over ws:// and wss:// connections.
  • Mosquitto Validation Suite: 104 automated tests against Mosquitto clients across TCP and WebSocket transports, covering all protocol versions and MQTT 5.0 features.
  • Handler tests: 30+ new tests covering compliance features, QoS 2 full flow, DUP handling, retry timer, CONNACK properties, topic aliases, flow control, max packet size, and server-initiated DISCONNECT.

Changed

  • Codec: MQTT 5.0 PUBLISH with empty topic is now valid when topic_alias property is present (per MQTT 5.0 spec section 3.3.2.1)
  • QoS 2 pending entries: pending_qos2_rx entries now include timestamps and retry counts; pending_qos2_tx entries are enriched maps with phase, packet, timestamp, and retry info
  • Router API: match/2match/3 and match_and_advance/2match_and_advance/3 with optional publisher parameter (backward compatible, defaults to nil)

Fixed

  • Server PUBREL handler now correctly extracts packet/opts from both legacy 2-tuple and new 4-tuple pending_qos2_rx entries

[0.6.1] - 2026-03-02

Fixed

  • Logo rendering on hexdocs.pm (use absolute URL for README image)
  • CI: increased GenServer.stop timeout in client tests for slower runners
  • CI: skip JSON payload tests on OTP < 27
  • Removed accidentally committed mqttx-0.1.0 directory

[0.6.0] - 2026-03-02

Added

  • Connection Supervision: MqttX.Client.Supervisor DynamicSupervisor for managed client connections
  • Rate Limiting: Per-client connection and message rate limiting for MQTT servers
    • MqttX.Server.RateLimiter module with ETS-based atomic counters
    • Connection rate limiting (configurable max connections per interval)
    • Per-client message rate limiting (configurable max messages per client per interval)
    • MQTT 5.0 reason code 0x96 (message_rate_too_high) sent for rate-limited QoS 1+ publishes
    • Integrated into both ThousandIsland and Ranch transport adapters
    • Configured via :rate_limit option in MqttX.Server.start_link/3
  • Capacity Planning guide: Device-per-vCPU sizing tables for IoT workloads (sleepy sensors through real-time streaming), instance sizing recommendations
  • Performance & Scaling guide: Architecture decisions, trie router internals, VM/OS tuning, and deployment guidelines
  • Project Branding: MqttX logo in README and hexdocs
  • EMQX interop test suite: 49 tests against live EMQX broker covering MQTT 5.0 features
  • Server Keepalive Timeout: Disconnects clients that stop sending packets within 1.5x keep_alive seconds (MQTT spec compliance)
    • Automatic timer start after CONNACK, reset on every received packet
    • Will message published on keepalive timeout (ungraceful disconnect)
  • Will Delay Interval (MQTT 5.0): Delays will message publication by will_delay_interval seconds after ungraceful disconnect
    • will_delay_interval: 0 (or MQTT 3.1.1) publishes immediately (backward compatible)
    • Will properties forwarded to handler
  • Session Expiry Timer (MQTT 5.0): Fires handle_session_expired/2 callback after session_expiry_interval seconds post-disconnect
    • 0 = expire immediately, 0xFFFFFFFF = never expire
    • New optional handle_session_expired/2 callback in MqttX.Server behaviour
  • Server-Initiated Disconnect: Kick clients with MQTT 5.0 reason codes
    • MqttX.Server.disconnect/3 sends DISCONNECT and closes connection
    • {:disconnect, reason_code, state} return type from handle_publish, handle_subscribe, handle_unsubscribe, handle_info
    • Ranch transport now properly forwards handle_info messages to handler (was silently dropping them)

Changed

  • Trie-based Topic Router: Replaced O(N) linear scan with a trie data structure for O(L+K) topic matching — independent of total subscription count. Same public API.
  • iodata Encoding: Socket sends use Codec.encode_iodata/2 in all transports, avoiding binary copies on every packet
  • Empty-buffer fast path: Skips binary concatenation when the TCP buffer is empty (common case)
  • Cached callback dispatch: function_exported? computed once at connection init, not per message
  • Direct inflight counter: O(1) flow control check instead of scanning pending_acks
  • Retained message delivery: Exact topic subscriptions use O(1) ETS lookup instead of full table scan

Fixed

  • Handler state lost on callbacks: notify_handler now correctly returns updated handler state (was silently discarding it)
  • Missing retries field in pending_acks: QoS 1/2 pending_acks entries now include retries: 0 (prevented retry tracking)
  • Session not saved on socket close: Session data now persists on unexpected TCP close/error, not just clean disconnect
  • Queued messages not delivered on reconnect: Buffer is now processed after CONNACK for persistent sessions
  • Protobuf codec crash on non-protobuf structs: Now returns {:error, {:protobuf_encode_error, _}} instead of raising
  • Protobuf codec crash on unknown module: Now returns {:error, {:unknown_message_module, module}} instead of raising
  • Removed dead outgoing topic alias code (topic_to_alias, next_alias) that was never functional
  • MqttX.version/0 now returns correct version string
  • Guides now included in hex.pm docs

[0.5.0] - 2026-01-15

Added

  • Telemetry Integration: Comprehensive :telemetry events for observability
    • Client events: connect, disconnect, publish, subscribe, message
    • Server events: client_connect, client_disconnect, publish, subscribe
    • New MqttX.Telemetry module with helper functions
  • Shared Subscriptions (MQTT 5.0): $share/group/topic pattern for load balancing
    • Round-robin distribution across group members
    • Router.match_and_advance/2 for stateful distribution
    • Automatic group cleanup when last member leaves
  • Topic Alias (MQTT 5.0): Bandwidth reduction for repeated topics
    • Client stores topic_alias_maximum from CONNACK
    • Resolves incoming topic aliases automatically
    • alias_to_topic map in connection state
  • Message Expiry (MQTT 5.0): Respects message_expiry_interval property
    • Retained messages stored with timestamp
    • Expired messages skipped on delivery
    • Remaining expiry sent in delivered messages
  • Flow Control (MQTT 5.0): Enforces receive_maximum for backpressure
    • Client tracks inflight QoS 1/2 message count
    • Returns {:error, :flow_control} when limit reached
    • Stores receive_maximum from CONNACK
  • Enhanced Auth (MQTT 5.0): SASL-style authentication callback
    • New handle_auth/3 callback in MqttX.Server behaviour
    • Default implementation returns error (not supported)
  • Request/Response (MQTT 5.0): Helper for request/response pattern
    • MqttX.Client.request/4 function
    • Passes response_topic and correlation_data properties
    • :properties option in publish/4

Changed

  • Transport adapters store retained messages with expiry metadata (5-tuple ETS format)
  • Client connection state includes topic alias and receive_maximum fields

[0.4.0] - 2026-01-15

Added

  • TLS/SSL Client Support: Optional TLS via :transport option (:tcp or :ssl)
    • :ssl_opts for SSL configuration (verify, cacerts, etc.)
    • Default port 8883 for SSL connections
  • QoS 2 Complete Flow: Full PUBREC/PUBREL/PUBCOMP handshake implementation
    • Client tracks outgoing QoS 2 messages through all phases
    • Client handles incoming QoS 2 messages correctly
  • Message Inflight Tracking: Timer-based retry for unacknowledged QoS 1/2 messages
    • Configurable :retry_interval option (default: 5000ms)
    • Automatic retry with dup: true flag
    • Max 3 retries before dropping message
  • Retained Messages: Server stores and delivers retained messages
    • ETS-based storage per server instance
    • Delivered to new subscribers on SUBSCRIBE
    • Empty payload clears retained message
  • Will Message Delivery: Server publishes will message on ungraceful disconnect
    • Stored from CONNECT packet
    • Published when connection closes without DISCONNECT
    • Supports retained will messages
  • Session Persistence: Configurable session storage for clean_session: false

Changed

  • Client connection state now tracks subscriptions for session persistence
  • Transport adapters create ETS tables for retained messages

[0.3.0] - 2026-01-15

Added

  • MQTT vs WebSocket JSON performance comparison in README
  • Comprehensive API reference documentation in README
  • New test files for improved coverage:
    • backoff_test.exs - exponential backoff logic tests
    • properties_test.exs - MQTT 5.0 properties encode/decode tests
    • client_test.exs - client API tests
    • server_test.exs - server behaviour and callback tests
  • MQTT 5.0 packet tests (AUTH, DISCONNECT with reason codes, properties)
  • MQTT 3.1 packet tests
  • Edge case tests (empty payload, large payload, max packet ID, unicode topics)

Changed

  • Updated ThousandIsland dependency to ~> 1.4 (was ~> 1.0)
  • Updated Ranch dependency to ~> 2.2 (was ~> 2.1)
  • Updated Protox dependency to ~> 2.0 (was >= 1.7.0)

Fixed

  • Formatting issues in thousand_island.ex
  • Protobuf payload codec updated for Protox 2.0 API changes (encode returns 3-tuple)

[0.2.0] - 2026-01-15

Added

  • handle_info/2 callback for MqttX.Server to handle custom messages (e.g., PubSub)
  • Support for outgoing PUBLISH via {:publish, topic, payload, state} return value
  • Enables bidirectional communication (server can push messages to connected clients)

[0.1.6] - 2026-01-15

Changed

  • Broadened protox dependency to support both 1.x and 2.x (>= 1.7.0)

[0.1.5] - 2026-01-15

Added

  • GitHub Actions CI workflow (tests on Elixir 1.18-1.19, OTP 27-28, dialyzer)
  • Roadmap section in README
  • Username/password example in client documentation
  • Changelog link on hex.pm package page
  • Hex.pm, Docs, and CI badges to README

Changed

  • Documentation landing page now shows README instead of module docs

Fixed

  • JSON payload codec now conditionally compiles only on OTP 27+
  • Code formatting issues
  • Version test no longer hardcodes version string
  • Dialyzer false positives for defensive pattern matching

[0.1.1] - 2026-01-15

Added

  • GitHub Actions CI workflow (tests, formatting, dialyzer)
  • Roadmap section in README
  • Username/password example in client documentation
  • Changelog link on hex.pm package page

Fixed

  • JSON codec description now correctly references built-in Erlang/OTP 27+ module

[0.1.0] - 2026-01-14

Added

  • Initial release
  • MQTT packet codec supporting MQTT 3.1, 3.1.1, and 5.0
  • All 15 MQTT packet types
  • MQTT 5.0 properties support
  • ThousandIsland transport adapter
  • Ranch transport adapter
  • MQTT Server behaviour with handler callbacks
  • Topic router with wildcard support (+, #)
  • MQTT Client with automatic reconnection
  • JSON payload codec (via built-in Erlang/OTP 27+ JSON module)
  • Protobuf payload codec (via Protox)
  • Raw binary payload codec
  • Comprehensive test suite