@@ -7,6 +7,74 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77
88## [ Unreleased]
99
10+ ### Added
11+
12+ - ` MqttException.ConnectionFailed ` — a new subtype for a connect attempt that failed before the
13+ broker accepted or refused it: DNS, TCP connect, TLS handshake, a socket that closed
14+ mid-handshake, or a CONNACK that never arrived.
15+
16+ ### Changed
17+
18+ - ** ` connect() ` no longer reports every failure as ` MqttException.ConnectionRejected ` .** It now
19+ classifies by which side of the handshake failed, because a consumer cannot write a correct retry
20+ policy without that distinction: a rejection means the broker read the CONNECT and said no, so
21+ retrying the same configuration is pointless, whereas a network failure is exactly what a retry
22+ is for. Previously both arrived as ` ConnectionRejected ` , so a TCP timeout or a TLS chain failure
23+ was indistinguishable from a bad password — a caller that stopped on ` ConnectionRejected ` gave up
24+ permanently on a transient network blip, and one that retried hammered a broker that would never
25+ accept it. Downstream consumers had to reach past the type and re-derive the answer from reason
26+ codes.
27+
28+ ` connect() ` now throws:
29+
30+ - ` ConnectionRejected ` only for a genuine CONNACK carrying an error reason code — including an
31+ unsupported protocol version that could not be renegotiated, and including a CONNACK whose
32+ reason code is ` PROTOCOL_ERROR ` , since the broker still answered. ` serverReference ` is
33+ preserved.
34+ - ` ConnectionFailed ` for transport and I/O failures, with the original platform exception kept as
35+ ` cause ` .
36+ - ` ProtocolError ` when the broker answered but violated the spec (a malformed CONNACK, an
37+ unexpected packet type mid-handshake).
38+
39+ Reason codes alone could not express this — a broker may legitimately refuse a CONNECT with
40+ ` PROTOCOL_ERROR ` , and a transport failure has no broker reason code at all and synthesises
41+ ` UNSPECIFIED_ERROR ` — so the connection layer now records the failure's origin explicitly.
42+
43+ ** Migration:** callers matching ` MqttException.ConnectionRejected ` to stop retrying keep working
44+ and get more accurate: only real refusals land there now. Code that relied on ` ConnectionRejected `
45+ as the catch-all for * any* connect failure should add a ` ConnectionFailed ` branch, or catch
46+ ` MqttException ` . Auto-reconnect behaviour is unchanged — it already classified by reason code and
47+ never stopped on a transport failure.
48+
49+ ### Fixed
50+
51+ - MQTT 5.0 → 3.1.1 version negotiation now also triggers when a broker ** silently closes** the
52+ connection on an MQTT 5.0 CONNECT instead of answering with an ` UNSUPPORTED_PROTOCOL_VERSION `
53+ CONNACK. Many older brokers and gateways (observed live on ` mqtt.defcon.run:4433 ` ) drop the
54+ connection without any reply, which previously surfaced as a bare transport error
55+ (` EOFException: Not enough data available ` ) — the Meshtastic Android app misreported it as a
56+ credentials problem, while iOS clients, which speak 3.1.1 natively, connected fine. The client
57+ and ` MqttClient.probe ` now retry once with MQTT 3.1.1 when ` negotiateVersion ` is enabled (the
58+ default). The fallback is phase-gated: it fires only when the connection failed * after* the
59+ CONNECT packet was written and * before* a CONNACK arrived. DNS, TCP, and TLS failures — and
60+ CONNACK timeouts, where the broker kept the connection open — do not trigger it, so real network
61+ errors are never masked and dead hosts don't pay a doubled connect latency. Those failures are
62+ still reported accurately, as ` ConnectionFailed ` per the classification change above. The probe's
63+ retry runs within the remaining ` timeoutMs ` budget, preserving its total wall-clock contract.
64+ ` probe ` additionally gains the explicit ` UNSUPPORTED_PROTOCOL_VERSION ` fallback the client
65+ already had, so probing a 3.1.1-only broker now reports ` Success ` instead of ` Rejected ` .
66+ - A failed handshake no longer leaks the socket. Rejections raised while awaiting CONNACK — an
67+ unexpected packet type, an AUTH packet during an MQTT 3.1.1 handshake — took a rethrow path that
68+ skipped transport cleanup, as did an invalid Maximum QoS value in the CONNACK. Handshake cleanup
69+ is now centralised and covers every rejection path.
70+ - Cancelling ` connect() ` mid-handshake no longer leaks the socket either. The cancellation branch
71+ rethrows to preserve structured concurrency and so bypassed the failure paths' cleanup; it now
72+ closes the transport on a ` NonCancellable ` context, because the coroutine is already cancelled
73+ and a plain suspending close would abort immediately.
74+ - ` probe() ` now reports a transport-factory failure as a ` ProbeResult ` instead of throwing. A
75+ composite factory with no delegate for the endpoint throws synchronously from ` create() ` , which
76+ was evaluated outside the classified path and escaped as a raw ` IllegalArgumentException ` .
77+
1078## [ 0.7.0] - 2026-07-26
1179
1280### Added
0 commit comments