fix(mqtt): a broker-refused CONNECT no longer reports a Healthy driver

MQTTnet 5 does not throw on an unsuccessful CONNACK — it returns the reason in
MqttClientConnectResult.ResultCode and v5 removed v4's ThrowOnNonSuccessfulConnectResponse,
so ConnectCoreAsync's unconditional SetState(Connected) sealed a wrong-password deployment
green: DriverState.Healthy / HostState.Running on a session that never authenticated.
Caught by the Task-13 Mosquitto fixture; 240 offline tests missed it.

ConnectCoreAsync now inspects the CONNACK and raises MqttConnectRejectedException.
Credentials/identity/protocol/Last-Will refusals are unrecoverable and set Faulted, which
stops the reconnect supervisor; transient refusals (ServerUnavailable, ServerBusy,
QuotaExceeded, UseAnotherServer, ConnectionRateExceeded, and anything unrecognised) stay
Reconnecting under backoff. MqttDriverProbe now shares the rejection wording, so the
AdminUI Test-connect button and the running driver can no longer disagree.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
Joseph Doherty
2026-07-24 18:02:00 -04:00
parent 033b3700c4
commit 2409d05a28
6 changed files with 646 additions and 204 deletions
@@ -152,16 +152,14 @@ public sealed class MqttDriverProbe : IDriverProbe
/// <c>Success</c> — never an exception, see the type remarks) to a targeted, credential-free
/// message.
/// </summary>
private static string ClassifyRejectedConnack(string target, MqttClientConnectResultCode code) => code switch
{
MqttClientConnectResultCode.NotAuthorized or MqttClientConnectResultCode.BadUserNameOrPassword
=> $"Broker at {target} rejected the credentials ({code}).",
MqttClientConnectResultCode.UnsupportedProtocolVersion
=> $"Broker at {target} does not support the configured protocol version ({code}).",
MqttClientConnectResultCode.Banned
=> $"Broker at {target} has banned this client ({code}).",
_ => $"Broker at {target} refused CONNECT: {code}.",
};
/// <remarks>
/// Delegates to <see cref="MqttConnection.DescribeConnackRejection"/> so the "Test connect"
/// button and the running driver report an identical rejection in identical words — they now
/// both classify CONNACK, and disagreeing about it was the exact confusion the Task-13 live
/// gate surfaced.
/// </remarks>
private static string ClassifyRejectedConnack(string target, MqttClientConnectResultCode code)
=> MqttConnection.DescribeConnackRejection(target, code);
/// <summary>
/// Classifies a thrown connect failure. Precedence mirrors