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 anis_error_code/1guard for the §2.4 rule that 0x80 and above means failure. Handler callbacks can return{:error, ReasonCodes.bad_authentication_method(), state}instead of a bare0x8C. 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/4accepts 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 pasthandle_connectcould therefore plant, or with an empty payload delete, a retained message on any topic its ACL forbids;deliver_retained_messages/2later 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 reachhandle_connect, sohandle_publish/4is 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_identifierunconditionally and used the resultingclient_idas thesession_storekey, 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 immutablesession_keycaptured ininit/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). Affectssession_store+clean_session: falsedeployments 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 usesinspect/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.
:ssland:wssuseverify: :verify_peerwith the OS trust store (:public_key.cacerts_get/0), SNI, HTTPS-style hostname checking, and TLS 1.2/1.3 only. Your:ssl_optsare merged over that baseline, soverify: :verify_nonestill 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, andhonor_cipher_order: truebeneath your:transport_options. - Server
max_packet_sizedefaults 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: :infinityrestores the old behavior. - Client
max_packet_sizedefaults 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_timeoutwas 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,:infinitydisables), which resets on any inbound data and therefore also covers clients that negotiatekeep_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:transportkey collided withMqttX.Server's own:transportoption (the adapter module) — a collision that made the documented Ranch setup fail at startup.:transportis 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/1no longer waits for CONNACK. The handshake is now event-driven, soconnect/1returns before the session is live and asubscribe/publishissued 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:connectedevent, or pass the newawait_connect: trueoption to block until the first attempt resolves — that form returns{:error, reason}(e.g.:econnrefusedor{: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 withinit/1,handle_message/4,handle_connected/2,handle_disconnected/2,handle_publish_error/4, andhandle_info/2(all defaulted), add it to a supervision tree as{MyClient, connect_opts}, and use the injectedpublish/subscribe/… helpers — including from inside callbacks, which run in the module's own process and so cannot deadlock the connection. SeeMqttX.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 toretry_intervallater 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_sizeand:await_connect. :ssland:public_keyadded toextra_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 raisedFunctionClauseError. 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/1returns a bare:incomplete, and the Subscription Identifier decoder'swithhad noelse, so that atom escaped intoProperties.decode/2and raisedCaseClauseError. 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/1andSessionExpiry.cancel/1ran 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
:hostare 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/SessionExpirytimers 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
receivebetween CONNECT and CONNACK: the handshake runs through the normal GenServer loop in a:connectingstate. 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.
Connectionships arestart: :transientchild spec, sodisconnect/2's normal stop is no longer resurrected by the DynamicSupervisor into an immediate reconnect. Callingdisconnect/2while 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_errorhandler 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
ETSStoretables are created via the supervisedETSOwner(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/3is logged instead of taking down the connection (which could poison-loop on QoS 1 redelivery) — andCode.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/1returns{:error, {:missing_option, :host | :client_id}}instead of raisingKeyError; its@specis widened toGenServer.on_start() | {:error, term()}.- Reconnect backoff state was discarded on several failure paths (the timer
fired but the delay never grew);
:jitteris clamped to [0, 0.9], since values >= 1 could produce a negative delay and crashProcess.send_after.
Fixed — broker / server
- The Ranch adapter was non-functional.
:ranch.handshake/1ran insideGenServer.init/1, deadlocking ranch's connection supervisor on the first inbound connection; it now uses the documented:proc_lib.spawn_link+enter_looppattern. Also fixed there: TLS ({:ssl, ...}) messages were handed to the user handler instead of the MQTT decoder, and close/stop paths skippedhandle_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.sleeptimers were uncancellable and fired even for clients that had reconnected, wiping live sessions. Replaced byMqttX.Server.SessionExpiry, mirroringWillDelay; 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/2fires 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: 1is 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,3delivers at the maximum QoS across a client's overlapping subscriptions (§3.3.4) instead of an arbitrary one; onlymatch_and_advance/3advances 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 areMqttX.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
ackslist. 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/1applies 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 as0x0000and 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.JSONis always defined; on runtimes without the nativeJSONmodule 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.exsfloor), 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 cleared —
mix compile --warnings-as-errors, which CI runs and which gates the release job, now passes. Twelve were unreachableatom_to_type/1clauses; three were unpinned bitstring size variables. - Dialyzer configured (
plt_add_appsfor the optional transports);stream_dataadded for property-based tests.mix dialyzernow passes cleanly, which matters because the release job declaresneeds: [test, dialyzer]— two errors were failing it:MqttX.Server.WillDelay'sctx()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/4was only inferred asinteger(), not thenon_neg_integer()the contract requires. The values do come from 32-bit unsigned properties, but nothing enforced it; a float or negative reachingProcess.send_after/3would crash the supervisedWillDelayprocess and silently drop the Will. The invariant is now enforced at the boundary by a guardedwill_delay_ms/1.
- Removed a defensive
_otherclause inMqttX.Packet.Properties.decode/2that dialyzer proved unreachable: it guarded against a property decoder leaking a bare atom, which is now prevented at its source by the explicitelseon theVarint.decode/1with.decode_utf8/1anddecode_binary/1were confirmed to return proper{:error, _}tuples. - Dependencies refreshed:
thousand_island1.5.0,bandit1.12.4,telemetry1.4.2,ranch2.2.1,protox2.0.9,ex_doc0.40.3. websock_adapterrequirement widened to~> 0.5 or ~> 0.6. 0.6.0 makes no API change — its only change is a defaultmax_frame_sizeof 10 MB (previously:infinity) — but the old~> 0.5requirement excluded it and would have blocked installation for any app already on 0.6. The WebSocket transport gained a:max_frame_sizeoption 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_errorevent, 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 fencedelixirblock in the README, guides, andAGENTS.mdmust parse, everyMqttX.*function it calls must exist at that arity,transport:values must name real modules, and the declared{:mqttx, "~> x.y"}version must matchmix.exs. This caught a landing-page example that raisedUndefinedFunctionErrorand a guide pinning an outdated version. - Fixed the
websock_adapterrequirement 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"inmix.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 tomix.exs. guides/server.mdnow documents every server callback.handle_puback/2andhandle_auth/3were 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 toMqttX.Server.behaviour_info(:callbacks)by a test.- The clean-session limitation is no longer buried. The note that
session_presentis alwaysfalsesat 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 MqttXexample, 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 theuse MqttXcallback 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_versionis now5(was4). 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_versionsintransport_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_packetinstead 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/1switched 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 withDUP=trueafterqos2_retry_intervalms, dropping afterqos2_max_retriesattempts. 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
PUBACKreason0x93(Receive Maximum exceeded). QoS 2 excess continues to receivePUBREC 0x93(spec permits either per-message ack or DISCONNECT).Critical: retained-publish packet IDs now come from
next_packet_idinstead of:rand.uniform/1— was colliding with the QoS sequence allocator and breaking ack matchingWill 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.WillDelayGenServer under the application supervisor; cancelled on same-client_idreconnectRate-limiter ETS table now created with
read_concurrency: true
Fixed (client — MqttX.Client.Connection)
- Critical: retry-loop reducer arity bug fixed — was crashing with
MatchErroronce both{:rx, _}and{:tx, _}pending_acksentries coexisted on the same connection keepalive == 0correctly disables the keepalive timer (was scheduling a zero-millisecond tight loop)- PINGRESP timeout: client now arms a deadline at
keepalive*1500mson 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_presentfrom CONNACK now surfaced to the handler in the:connectedevent; warns on MQTT-3.2.2-2 violation (clean_session=truebutsession_present=true)- Server-initiated DISCONNECT now closes the socket immediately and schedules
reconnect (was waiting for
:tcp_closedto land separately) - AUTH continuation in
wait_for_connackre-armsset_socket_active/1so 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_idskips IDs currently inpending_acks(§2.2.1)schedule_reconnectcancels any existing reconnect timer (no more stacked timers when several disconnect events fire)set_socket_activeguards againstnilsocket (server-disconnect race)- Pending SUBACK/UNSUBACK callers monitored via
Process.monitor; entry dropped on:DOWN(was leaking afterGenServer.calltimeouts)
Fixed (WebSocket client — MqttX.Client.WebSocket)
Sec-WebSocket-Acceptnow validated asBase64(SHA1(client_key + magic GUID))per RFC 6455 §4.1; status line strictly matched asHTTP/1.x 101 …Sec-WebSocket-Protocolecho checked (warn-only on missing — common with MQTT brokers that nonetheless speak it correctly)- FIN-bit fragmentation: opaque
frag_statethreaded acrossdecode_frames/2calls so multi-frame messages split across TCP reads reassemble correctly - Frame masking switched from byte-by-byte recursion to
:crypto.exor/2against a pre-padded mask buffer (orders-of-magnitude faster on large payloads)
Added
MqttX.Session.ETSOwner— a long-lived owner of the default:mqttx_sessionsETS table under the application supervisor. Previously the table was owned by whichever client first calledMqttX.Session.ETSStore.init/1, so all sessions were lost when that process exited. The table is now created withread_concurrency: true, write_concurrency: true.MqttX.Server.WillDelay— supervised GenServer that owns Will Delay Interval timers across connection lifecycles, with per-client_idcancellation.:supported_versionsserver 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, confusinghandle_publishwithhandle_mqtt_event, randomclient_idper connect, default-to-QoS-2,$SYSexclusion). 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.mdis now a symlink toAGENTS.mdfor tool compatibility.- README "Common Patterns" section with worked examples for receiving
messages on the client (
handle_mqtt_event/3), broadcasting from the server viahandle_info/2, and resuming MQTT 5.0 sessions withsession_expiry_interval. - README "Common Pitfalls" section covering session-store /
clean_sessioninteraction, server keepalive override, max-packet-size enforcement, publish-vs-subscribe wildcard rules, and$SYStopic exclusion.
[0.9.0] - 2026-03-30
Added
- Server keepalive override (MQTT 5.0): Configurable
server_keep_aliveintransport_opts— server sends keepalive override in CONNACK and uses it for the keepalive timer when protocol version >= 5 handle_connect/4callback (optional): Newhandle_connect(client_id, credentials, connect_info, state)callback that receives connection metadata (protocol_version,keep_alive) separately from credentials. Existinghandle_connect/3continues to work unchanged —handle_connect/4takes 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/3callback (§4.12)
- EMQX Cloud Interop: 49 automated interop tests against EMQX Cloud broker
- SUBACK Reason Code Checking:
subscribe/3now 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/2accepts:reason_codeand:propertiesoptions for MQTT 5.0 graceful disconnect - WebSocket Client Transport: Connect to brokers over WebSocket with
transport: :wsortransport: :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_mismatchin WebSocket transportclose/1(now returns:okper 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, andsubscription_identifier_availablein CONNACK for MQTT 5.0 connections - retain_handling support: Subscription option
retain_handling: 2suppresses retained message delivery on subscribe - no_local support:
Router.match/3andRouter.match_and_advance/3accept optionalpublisherparameter to filter out subscriptions withno_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_aliasproperty resolved automatically. Server advertisestopic_alias_maximumin CONNACK. Alias-only publishes (empty topic) look up stored mapping. - Flow Control / Receive Maximum (MQTT 5.0 Server): Server enforces
receive_maximumfor incoming QoS 2 messages. Excess publishes receive PUBREC with reason code0x93(Receive Maximum exceeded). Server advertisesreceive_maximumin CONNACK. - Maximum Packet Size (MQTT 5.0 Server): Configurable
max_packet_sizeoption. Oversized incoming packets trigger DISCONNECT with reason code0x95(Packet too large). Outgoing publishes exceeding client'smaximum_packet_sizeare silently dropped. Server advertisesmaximum_packet_sizein CONNACK when configured. - WebSocket Transport: MQTT over WebSocket via Bandit, supporting all MQTT protocol features over
ws://andwss://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_aliasproperty is present (per MQTT 5.0 spec section 3.3.2.1) - QoS 2 pending entries:
pending_qos2_rxentries now include timestamps and retry counts;pending_qos2_txentries are enriched maps with phase, packet, timestamp, and retry info - Router API:
match/2→match/3andmatch_and_advance/2→match_and_advance/3with optionalpublisherparameter (backward compatible, defaults tonil)
Fixed
- Server PUBREL handler now correctly extracts packet/opts from both legacy 2-tuple and new 4-tuple
pending_qos2_rxentries
[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.SupervisorDynamicSupervisor for managed client connectionsMqttX.Client.connect_supervised/1starts connections under the supervisor with automatic crash recoveryMqttX.Client.list/0lists all registered connections viaMqttX.ClientRegistryMqttX.Client.whereis/1looks up connections by client_id- Connections auto-register in
MqttX.ClientRegistryon init
- Rate Limiting: Per-client connection and message rate limiting for MQTT servers
MqttX.Server.RateLimitermodule 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_limitoption inMqttX.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_aliveseconds (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_intervalseconds after ungraceful disconnectwill_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/2callback aftersession_expiry_intervalseconds post-disconnect0= expire immediately,0xFFFFFFFF= never expire- New optional
handle_session_expired/2callback inMqttX.Serverbehaviour
- Server-Initiated Disconnect: Kick clients with MQTT 5.0 reason codes
MqttX.Server.disconnect/3sends DISCONNECT and closes connection{:disconnect, reason_code, state}return type fromhandle_publish,handle_subscribe,handle_unsubscribe,handle_info- Ranch transport now properly forwards
handle_infomessages 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/2in 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_handlernow 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/0now returns correct version string- Guides now included in hex.pm docs
[0.5.0] - 2026-01-15
Added
- Telemetry Integration: Comprehensive
:telemetryevents for observability- Client events: connect, disconnect, publish, subscribe, message
- Server events: client_connect, client_disconnect, publish, subscribe
- New
MqttX.Telemetrymodule with helper functions
- Shared Subscriptions (MQTT 5.0):
$share/group/topicpattern for load balancing- Round-robin distribution across group members
Router.match_and_advance/2for stateful distribution- Automatic group cleanup when last member leaves
- Topic Alias (MQTT 5.0): Bandwidth reduction for repeated topics
- Client stores
topic_alias_maximumfrom CONNACK - Resolves incoming topic aliases automatically
alias_to_topicmap in connection state
- Client stores
- Message Expiry (MQTT 5.0): Respects
message_expiry_intervalproperty- Retained messages stored with timestamp
- Expired messages skipped on delivery
- Remaining expiry sent in delivered messages
- Flow Control (MQTT 5.0): Enforces
receive_maximumfor backpressure- Client tracks inflight QoS 1/2 message count
- Returns
{:error, :flow_control}when limit reached - Stores
receive_maximumfrom CONNACK
- Enhanced Auth (MQTT 5.0): SASL-style authentication callback
- New
handle_auth/3callback inMqttX.Serverbehaviour - Default implementation returns error (not supported)
- New
- Request/Response (MQTT 5.0): Helper for request/response pattern
MqttX.Client.request/4function- Passes
response_topicandcorrelation_dataproperties :propertiesoption inpublish/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
:transportoption (:tcpor:ssl):ssl_optsfor 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_intervaloption (default: 5000ms) - Automatic retry with
dup: trueflag - Max 3 retries before dropping message
- Configurable
- 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: falseMqttX.Session.Storebehaviour for custom implementationsMqttX.Session.ETSStorebuilt-in in-memory store- Saves/restores subscriptions, pending messages, packet IDs
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 testsproperties_test.exs- MQTT 5.0 properties encode/decode testsclient_test.exs- client API testsserver_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/2callback 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