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
@@ -89,6 +89,43 @@ public enum MqttConnectionState
Disposed = 4,
}
/// <summary>
/// The broker completed the handshake and <b>refused</b> the CONNECT, returning a non-success
/// CONNACK reason code.
/// </summary>
/// <remarks>
/// <para>
/// This exists because <b>MQTTnet 5 does not throw on an unsuccessful CONNACK</b> — it returns
/// the outcome in <see cref="MqttClientConnectResult.ResultCode"/> and leaves the client
/// disconnected, and v5 removed v4's <c>ThrowOnNonSuccessfulConnectResponse</c> option, so
/// inspecting the result is the only way to see it. A caller that ignores the result gets a
/// connect that "succeeded" against a broker which rejected it: a driver configured with the
/// wrong broker password would report <c>Healthy</c> and never receive a single value. That is
/// exactly what the Task-13 live fixture caught.
/// </para>
/// <para>
/// <see cref="IsUnrecoverable"/> distinguishes a rejection no amount of retrying can fix
/// (credentials, identity, protocol, an invalid Last-Will) from a genuinely transient refusal
/// (broker unavailable/busy, quota, rate limit). See
/// <see cref="MqttConnection.IsUnrecoverableConnackRejection"/> for the full mapping.
/// </para>
/// </remarks>
public sealed class MqttConnectRejectedException : Exception
{
internal MqttConnectRejectedException(MqttClientConnectResultCode resultCode, bool isUnrecoverable, string message)
: base(message)
{
ResultCode = resultCode;
IsUnrecoverable = isUnrecoverable;
}
/// <summary>Whether retrying against this broker with this configuration can never succeed.</summary>
public bool IsUnrecoverable { get; }
/// <summary>The CONNACK reason code the broker returned.</summary>
public MqttClientConnectResultCode ResultCode { get; }
}
/// <summary>
/// Owns the driver's single live MQTTnet-5 client: assembles the broker connection options
/// from <see cref="MqttDriverOptions"/> (endpoint, protocol version, session, credentials,
@@ -450,6 +487,12 @@ public sealed class MqttConnection : IAsyncDisposable, IMqttSubscribeTransport
/// <exception cref="ObjectDisposedException">
/// The connection was disposed, either before the attempt started or while it was in flight.
/// </exception>
/// <exception cref="MqttConnectRejectedException">
/// The broker refused the CONNECT (non-success CONNACK — bad credentials, unsupported protocol
/// version, server unavailable …). MQTTnet reports this as a <i>result</i>, not an exception,
/// so this method inspects the CONNACK and raises it: a caller that treats "did not throw" as
/// "connected" would otherwise report a healthy session the broker never granted.
/// </exception>
public async Task ConnectAsync(CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(Disposed, this);
@@ -584,7 +627,15 @@ public sealed class MqttConnection : IAsyncDisposable, IMqttSubscribeTransport
return false;
}
await client.ConnectAsync(clientOptions, linked.Token).ConfigureAwait(false);
var connack = await client.ConnectAsync(clientOptions, linked.Token).ConfigureAwait(false);
// MQTTnet 5 does NOT throw for a broker that refused the CONNECT — it hands the reason
// back here and leaves the client disconnected. Skipping this check is how a wrong broker
// password produced a Healthy driver that never received a value (Task-13 live gate).
if (connack is not null && connack.ResultCode != MqttClientConnectResultCode.Success)
{
throw BuildRejection(connack, supervisorAttempt);
}
if (AfterConnectHookForTests is { } hook)
{
@@ -618,6 +669,12 @@ public sealed class MqttConnection : IAsyncDisposable, IMqttSubscribeTransport
{
throw;
}
// A refused CONNECT is already fully classified — it must not be re-wrapped as a timeout or a
// cancellation just because a token happened to fire while the CONNACK was being read.
catch (MqttConnectRejectedException)
{
throw;
}
catch (Exception ex)
{
throw Classify(ex, cancellationToken, deadline);
@@ -628,6 +685,101 @@ public sealed class MqttConnection : IAsyncDisposable, IMqttSubscribeTransport
}
}
/// <summary>
/// Turns a refused CONNACK into the thrown contract, setting <see cref="MqttConnectionState.Faulted"/>
/// first when the refusal is unrecoverable — which is what stops the supervisor
/// (<see cref="ReconnectSupervisorAsync"/> returns on <c>Faulted</c>) from retrying every
/// backoff period against a broker that will never accept these credentials.
/// </summary>
private MqttConnectRejectedException BuildRejection(MqttClientConnectResult connack, bool supervisorAttempt)
{
var unrecoverable = IsUnrecoverableConnackRejection(connack.ResultCode);
var message = DescribeConnackRejection($"{_options.Host}:{_options.Port}", connack.ResultCode);
if (unrecoverable)
{
SetState(MqttConnectionState.Faulted);
_logger?.LogError(
"MQTT driver '{DriverId}': {Message} Retrying cannot help; fix the configuration and redeploy.",
_driverId,
message);
}
else
{
// Retryable: leave the state alone — Disconnected before a first connect, Reconnecting
// under the supervisor — so the backoff loop keeps trying at its bounded rate.
_logger?.LogWarning(
"MQTT driver '{DriverId}': {Message} Treating as transient{Suffix}.",
_driverId,
message,
supervisorAttempt ? " and continuing to retry" : string.Empty);
}
return new MqttConnectRejectedException(connack.ResultCode, unrecoverable, message);
}
/// <summary>
/// Whether a refused CONNACK can never be fixed by retrying, and so must fault the connection
/// rather than leave it looping.
/// </summary>
/// <remarks>
/// <para>
/// Unrecoverable covers credentials/identity (<c>BadUserNameOrPassword</c>,
/// <c>NotAuthorized</c>, <c>ClientIdentifierNotValid</c>, <c>Banned</c>,
/// <c>BadAuthenticationMethod</c>), protocol mismatch (<c>UnsupportedProtocolVersion</c>,
/// <c>MalformedPacket</c>, <c>ProtocolError</c>, <c>PacketTooLarge</c>) and a CONNECT whose
/// Last-Will the broker will not accept (<c>TopicNameInvalid</c>,
/// <c>PayloadFormatInvalid</c>, <c>RetainNotSupported</c>, <c>QoSNotSupported</c>). Every
/// one is a property of the configuration we keep re-sending.
/// </para>
/// <para>
/// <c>ServerMoved</c> (0x9D) is unrecoverable and <c>UseAnotherServer</c> (0x9C) is not,
/// because MQTT 5 defines the first as "permanently use another server" and the second as
/// "temporarily use another server" — the spec, not a guess.
/// </para>
/// <para>
/// Everything else — including <c>UnspecifiedError</c>, <c>ImplementationSpecificError</c>
/// and any code a future broker invents — is treated as transient. That default is
/// deliberate: wrongly faulting a transient refusal takes a recoverable driver down until
/// someone redeploys, whereas wrongly retrying a permanent one costs a bounded, visibly
/// <c>Reconnecting</c>, backoff-paced attempt that names the reason code in the log.
/// </para>
/// </remarks>
internal static bool IsUnrecoverableConnackRejection(MqttClientConnectResultCode code) => code switch
{
MqttClientConnectResultCode.MalformedPacket
or MqttClientConnectResultCode.ProtocolError
or MqttClientConnectResultCode.UnsupportedProtocolVersion
or MqttClientConnectResultCode.ClientIdentifierNotValid
or MqttClientConnectResultCode.BadUserNameOrPassword
or MqttClientConnectResultCode.NotAuthorized
or MqttClientConnectResultCode.Banned
or MqttClientConnectResultCode.BadAuthenticationMethod
or MqttClientConnectResultCode.TopicNameInvalid
or MqttClientConnectResultCode.PacketTooLarge
or MqttClientConnectResultCode.PayloadFormatInvalid
or MqttClientConnectResultCode.RetainNotSupported
or MqttClientConnectResultCode.QoSNotSupported
or MqttClientConnectResultCode.ServerMoved => true,
_ => false,
};
/// <summary>
/// Renders a refused CONNACK as a targeted, credential-free operator message. Shared with
/// <c>MqttDriverProbe</c> so the AdminUI "Test connect" button and the running driver describe
/// the same rejection in the same words.
/// </summary>
internal static string DescribeConnackRejection(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}.",
};
/// <summary>
/// Starts the supervisor on the first <i>successful</i> caller connect. Always called while
/// holding the lifecycle gate, so the <c>??=</c> needs no further synchronisation.
@@ -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
@@ -88,34 +88,30 @@ public sealed class PlainMqttLiveTests(MqttFixture fixture)
/// <c>allow_anonymous false</c>, so if bad credentials produced a session the fixture would be
/// misconfigured and "connected" would prove nothing about auth.
/// <para>
/// <b>LIVE-GATE FINDING (2026-07-24) — the invariant asserted here is deliberately "no
/// session", not "ConnectAsync throws".</b> Against real Mosquitto,
/// <see cref="MqttConnection.ConnectAsync"/> <b>returns normally</b> for a CONNECT the
/// broker rejects as not-authorized: MQTTnet 5 does not throw on an unsuccessful CONNACK,
/// and <c>ConnectCoreAsync</c> then unconditionally publishes
/// <see cref="MqttConnectionState.Connected"/> and starts the reconnect supervisor.
/// Measured at +300 ms with a wrong password: <c>thrown=&lt;none&gt;</c>,
/// <c>IsConnected=False</c>, <c>State=Reconnecting</c> (the broker's socket close had
/// fired <c>DisconnectedAsync</c> by then). Broker side:
/// <c>Client … disconnected, not authorised.</c>
/// <b>LIVE-GATE FINDING (2026-07-24), now FIXED — this test was tightened accordingly.</b>
/// Against real Mosquitto, <see cref="MqttConnection.ConnectAsync"/> originally
/// <b>returned normally</b> for a CONNECT the broker rejected as not-authorized: MQTTnet 5
/// does not throw on an unsuccessful CONNACK, and <c>ConnectCoreAsync</c> then
/// unconditionally published <see cref="MqttConnectionState.Connected"/> and started the
/// reconnect supervisor. Measured at +300 ms with a wrong password:
/// <c>thrown=&lt;none&gt;</c>, <c>IsConnected=False</c>, <c>State=Reconnecting</c>. Broker
/// side: <c>Client … disconnected, not authorised.</c> The operator-visible consequence was
/// that <c>InitializeAsync</c> reported <c>DriverState.Healthy</c> / <c>HostState.Running</c>
/// off a connect that never authenticated, so a deployment carrying the wrong broker
/// password sealed green.
/// </para>
/// <para>
/// That is a driver-side defect, not a fixture one — <c>InitializeAsync</c> reports
/// <c>DriverState.Healthy</c> / <c>HostState.Running</c> off a connect that never
/// authenticated, so a deployment carrying the wrong broker password seals green and the
/// AdminUI "Test connect" probe is expected to agree. Fixing it belongs in the driver
/// (<c>MqttConnection</c> must inspect the connect result / opt into MQTTnet's
/// throw-on-unsuccessful-CONNACK), which is outside this task's file scope; it is reported
/// as a live-gate finding.
/// </para>
/// <para>
/// The test is written to hold either way: it records whether the call threw (as a
/// diagnostic, not an assertion) and asserts only the invariant that must be true under
/// both behaviours — no session is ever established. A driver fixed to throw still passes.
/// <c>ConnectCoreAsync</c> now inspects the CONNACK and raises
/// <see cref="MqttConnectRejectedException"/>, so this asserts the strong form: the
/// rejection is <b>surfaced</b>, classified as unrecoverable, and lands the connection in
/// <see cref="MqttConnectionState.Faulted"/>. The original weak invariant — polled over 5 s
/// so the optimistic <c>Connected</c> window cannot pass it for the wrong reason — is kept
/// underneath it, because "no session is ever established" is the property that actually
/// matters to a consumer and it must hold regardless of how the failure is reported.
/// </para>
/// </summary>
[Fact]
public async Task Tls_connect_with_wrong_password_never_establishes_a_session()
public async Task Tls_connect_with_wrong_password_is_rejected_and_surfaced()
{
SkipIfUnconfigured();
@@ -123,34 +119,32 @@ public sealed class PlainMqttLiveTests(MqttFixture fixture)
var options = TlsOptions() with { Password = _fx.Password + "-definitely-wrong" };
await using var connection = new MqttConnection(options, "mqtt-live-badpass");
Exception? thrown = null;
try
{
await connection.ConnectAsync(ct);
}
catch (Exception ex)
{
thrown = ex;
}
var rejection = await Should.ThrowAsync<MqttConnectRejectedException>(
async () => await connection.ConnectAsync(ct));
// Poll rather than sample once: the rejection arrives as a broker-initiated socket close a
// few milliseconds after ConnectAsync returns, so a single immediate read could catch the
// optimistic Connected state and pass for the wrong reason. Every sample must show no
// session — a correct password would make IsConnected true within the first sample.
// Mosquitto answers bad credentials with NotAuthorized (MQTT 5) / 0x05 (3.1.1); either maps to
// the same unrecoverable classification, which is what stops the reconnect supervisor.
rejection.IsUnrecoverable.ShouldBeTrue(
$"a broker that refused the credentials returned {rejection.ResultCode}, classified as retryable");
rejection.Message.ShouldNotContain(options.Password ?? "<unset>", Case.Sensitive);
connection.State.ShouldBe(MqttConnectionState.Faulted);
// Poll rather than sample once: a regression that went back to publishing an optimistic
// Connected would still read "not connected" in the first millisecond. Every sample must show
// no session — a correct password would make IsConnected true within the first sample.
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
while (DateTime.UtcNow < deadline)
{
connection.IsConnected.ShouldBeFalse(
"a CONNECT the broker rejected as not-authorized must never yield a usable session");
connection.State.ShouldNotBe(
MqttConnectionState.Connected,
"a rejected CONNECT must never advertise a healthy session");
await Task.Delay(100, ct);
}
connection.State.ShouldNotBe(
MqttConnectionState.Connected,
"the settled state after a rejected CONNECT must not advertise a healthy session");
TestContext.Current.SendDiagnosticMessage(
$"wrong-password connect: threw={thrown?.GetType().Name ?? "<nothing see the LIVE-GATE FINDING in this test's docs>"}, "
$"wrong-password connect: {rejection.ResultCode} (unrecoverable={rejection.IsUnrecoverable}), "
+ $"IsConnected={connection.IsConnected}, State={connection.State}.");
}
@@ -0,0 +1,189 @@
using System.Net;
using System.Net.Sockets;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
/// <summary>
/// The smallest broker that can complete an MQTT 3.1.1 handshake: accept TCP, answer CONNECT with
/// a CONNACK, answer PINGREQ with PINGRESP, and nothing else. That is enough for MQTTnet to report
/// a real connection — which is what makes a real drop (<see cref="DropAll"/>) drive a real
/// <c>DisconnectedAsync</c> and a real reconnect, rather than a test seam standing in for one.
/// </summary>
/// <remarks>
/// Shared across suites rather than nested in one, because both the connection-level and the
/// driver-level rejection tests need it: the driver-level one is the pin for the actual
/// operator-visible symptom (a rejected CONNECT must not report <c>Healthy</c>).
/// </remarks>
internal sealed class MiniBroker : IDisposable
{
/// <summary>CONNACK "Connection Accepted" (MQTT 3.1.1 return code 0).</summary>
internal const byte ReturnCodeAccepted = 0x00;
/// <summary>CONNACK "Identifier rejected" — MQTTnet maps it to <c>ClientIdentifierNotValid</c>.</summary>
internal const byte ReturnCodeIdentifierRejected = 0x02;
/// <summary>CONNACK "Server unavailable" — MQTTnet maps it to <c>ServerUnavailable</c> (transient).</summary>
internal const byte ReturnCodeServerUnavailable = 0x03;
/// <summary>CONNACK "Bad user name or password" — MQTTnet maps it to <c>BadUserNameOrPassword</c>.</summary>
internal const byte ReturnCodeBadUserNameOrPassword = 0x04;
/// <summary>CONNACK "Not authorized" — MQTTnet maps it to <c>NotAuthorized</c> (unrecoverable).</summary>
internal const byte ReturnCodeNotAuthorized = 0x05;
private static readonly byte[] PingResp = [0xD0, 0x00];
private readonly CancellationTokenSource _cts = new();
private readonly HashSet<TcpClient> _live = [];
private readonly TcpListener _listener;
private int _acceptedCount;
public MiniBroker()
{
_listener = new TcpListener(IPAddress.Loopback, 0);
_listener.Start();
Port = ((IPEndPoint)_listener.LocalEndpoint).Port;
_ = Task.Run(AcceptLoopAsync);
}
public int Port { get; }
/// <summary>When set, TCP is still accepted but CONNECT is never answered (frozen peer).</summary>
public bool Blackhole { get; set; }
/// <summary>
/// The MQTT 3.1.1 return code answered to every CONNECT. Anything but
/// <see cref="ReturnCodeAccepted"/> is a broker that completes the handshake and then
/// <i>refuses</i> — the shape MQTTnet reports as a result rather than an exception, and the one
/// a real Mosquitto with the wrong password produces.
/// </summary>
public byte ConnAckReturnCode { get; set; } = ReturnCodeAccepted;
/// <summary>Total TCP connections accepted since construction — counts reconnect attempts.</summary>
public int AcceptedCount => Volatile.Read(ref _acceptedCount);
/// <summary>
/// Sockets the broker still has open. Drops to zero only once the peer actually closes, so a
/// leaked-but-live client keeps this above zero.
/// </summary>
public int LiveConnections
{
get
{
lock (_live)
{
return _live.Count;
}
}
}
/// <summary>Kills every established session — the "broker went away" event.</summary>
public void DropAll()
{
lock (_live)
{
foreach (var client in _live)
{
try
{
client.Close();
}
catch (Exception)
{
// Already gone.
}
}
_live.Clear();
}
}
public void Dispose()
{
_cts.Cancel();
_listener.Stop();
DropAll();
_cts.Dispose();
}
private async Task AcceptLoopAsync()
{
try
{
while (!_cts.IsCancellationRequested)
{
var client = await _listener.AcceptTcpClientAsync(_cts.Token);
Interlocked.Increment(ref _acceptedCount);
lock (_live)
{
_live.Add(client);
}
_ = Task.Run(() => ServeAsync(client));
}
}
catch (Exception)
{
// Listener stopped / token cancelled — the fixture is going away.
}
}
private async Task ServeAsync(TcpClient client)
{
var buffer = new byte[1024];
try
{
var stream = client.GetStream();
while (!_cts.IsCancellationRequested)
{
var read = await stream.ReadAsync(buffer, _cts.Token);
if (read == 0)
{
break; // peer closed — this is how a disposed client stops counting as live
}
if (Blackhole)
{
continue;
}
var packetType = buffer[0] & 0xF0;
if (packetType == 0x10)
{
await stream.WriteAsync(new byte[] { 0x20, 0x02, 0x00, ConnAckReturnCode }, _cts.Token);
// A real broker closes the socket straight after refusing, which is what drives
// MQTTnet's DisconnectedAsync. Mirroring it keeps the fake honest.
if (ConnAckReturnCode != ReturnCodeAccepted)
{
break;
}
}
else if (packetType == 0xC0)
{
await stream.WriteAsync(PingResp, _cts.Token);
}
}
}
catch (Exception)
{
// Socket torn down by either side.
}
finally
{
lock (_live)
{
_live.Remove(client);
}
try
{
client.Dispose();
}
catch (Exception)
{
// Already gone.
}
}
}
}
@@ -927,6 +927,168 @@ public sealed class MqttConnectionTests
.ShouldBeTrue();
}
// ---------------------------------------------------------------------------------
// Refused CONNACK — MQTTnet 5 reports it as a RESULT, never an exception
// ---------------------------------------------------------------------------------
/// <summary>
/// The Task-13 live-gate defect, reproduced offline: Mosquitto genuinely rejected a wrong
/// password (<c>Client … disconnected, not authorised.</c>) and <c>ConnectAsync</c> returned
/// normally, because MQTTnet 5 hands a refused CONNACK back in
/// <c>MqttClientConnectResult.ResultCode</c> and v5 removed v4's
/// <c>ThrowOnNonSuccessfulConnectResponse</c> option.
/// </summary>
[Fact]
public async Task ConnectAsync_BrokerRefusesTheCredentials_Throws_AndFaults()
{
using var broker = new MiniBroker { ConnAckReturnCode = MiniBroker.ReturnCodeNotAuthorized };
await using var conn = new MqttConnection(BrokerOpts(broker.Port), driverId: "t", logger: null);
var ex = await Should.ThrowAsync<MqttConnectRejectedException>(
async () => await conn.ConnectAsync(CancellationToken.None));
ex.ResultCode.ShouldBe(MqttClientConnectResultCode.NotAuthorized);
ex.IsUnrecoverable.ShouldBeTrue();
ex.Message.ShouldContain("rejected the credentials");
conn.IsConnected.ShouldBeFalse();
conn.State.ShouldBe(MqttConnectionState.Faulted);
}
/// <summary>Credentials must never reach the message an operator (or a log sink) will see.</summary>
[Fact]
public async Task ConnectAsync_RefusedCredentials_DoesNotLeakThePasswordIntoTheRejection()
{
const string secret = "sup3r-s3cret-broker-pw";
using var broker = new MiniBroker { ConnAckReturnCode = MiniBroker.ReturnCodeBadUserNameOrPassword };
var opts = BrokerOpts(broker.Port) with { Username = "svc", Password = secret };
await using var conn = new MqttConnection(opts, driverId: "t", logger: null);
var ex = await Should.ThrowAsync<MqttConnectRejectedException>(
async () => await conn.ConnectAsync(CancellationToken.None));
ex.ToString().ShouldNotContain(secret);
}
/// <summary>
/// An unrecoverable rejection must not leave a supervisor grinding away every backoff period
/// against a broker that will never accept these credentials.
/// </summary>
[Fact]
public async Task ConnectAsync_RefusedCredentials_StartsNoReconnectSupervisor()
{
using var broker = new MiniBroker { ConnAckReturnCode = MiniBroker.ReturnCodeNotAuthorized };
var opts = BrokerOpts(broker.Port) with { ReconnectMinBackoffSeconds = 1, ReconnectMaxBackoffSeconds = 1 };
await using var conn = new MqttConnection(opts, driverId: "t", logger: null);
await Should.ThrowAsync<MqttConnectRejectedException>(
async () => await conn.ConnectAsync(CancellationToken.None));
var acceptedAfterTheAttempt = broker.AcceptedCount;
await Task.Delay(TimeSpan.FromSeconds(4), TestContext.Current.CancellationToken);
broker.AcceptedCount.ShouldBe(acceptedAfterTheAttempt, "a hopeless rejection is being retried");
conn.State.ShouldBe(MqttConnectionState.Faulted);
}
/// <summary>
/// A genuinely transient refusal is NOT a fault. Faulting on <c>ServerUnavailable</c> would take
/// a recoverable driver down until someone redeployed it.
/// </summary>
[Fact]
public async Task ConnectAsync_TransientRefusal_Throws_ButStaysRetryable()
{
using var broker = new MiniBroker { ConnAckReturnCode = MiniBroker.ReturnCodeServerUnavailable };
await using var conn = new MqttConnection(BrokerOpts(broker.Port), driverId: "t", logger: null);
var ex = await Should.ThrowAsync<MqttConnectRejectedException>(
async () => await conn.ConnectAsync(CancellationToken.None));
ex.ResultCode.ShouldBe(MqttClientConnectResultCode.ServerUnavailable);
ex.IsUnrecoverable.ShouldBeFalse();
conn.State.ShouldNotBe(MqttConnectionState.Faulted);
}
[Theory]
[InlineData(MqttClientConnectResultCode.NotAuthorized, true)]
[InlineData(MqttClientConnectResultCode.BadUserNameOrPassword, true)]
[InlineData(MqttClientConnectResultCode.ClientIdentifierNotValid, true)]
[InlineData(MqttClientConnectResultCode.UnsupportedProtocolVersion, true)]
[InlineData(MqttClientConnectResultCode.Banned, true)]
[InlineData(MqttClientConnectResultCode.BadAuthenticationMethod, true)]
[InlineData(MqttClientConnectResultCode.TopicNameInvalid, true)]
[InlineData(MqttClientConnectResultCode.QoSNotSupported, true)]
// MQTT 5: 0x9D "permanently use another server" vs 0x9C "temporarily use another server".
[InlineData(MqttClientConnectResultCode.ServerMoved, true)]
[InlineData(MqttClientConnectResultCode.UseAnotherServer, false)]
[InlineData(MqttClientConnectResultCode.ServerUnavailable, false)]
[InlineData(MqttClientConnectResultCode.ServerBusy, false)]
[InlineData(MqttClientConnectResultCode.QuotaExceeded, false)]
[InlineData(MqttClientConnectResultCode.ConnectionRateExceeded, false)]
// Carries no actionable information, so it must not be allowed to permanently fault a driver.
[InlineData(MqttClientConnectResultCode.UnspecifiedError, false)]
[InlineData(MqttClientConnectResultCode.ImplementationSpecificError, false)]
public void IsUnrecoverableConnackRejection_ClassifiesByWhetherRetryingCouldEverHelp(
MqttClientConnectResultCode code,
bool expected)
=> MqttConnection.IsUnrecoverableConnackRejection(code).ShouldBe(expected);
/// <summary>
/// A reason code this build has never seen must default to retryable — see the classification
/// remarks for why the ambiguous case fails toward availability, not toward Faulted.
/// </summary>
[Fact]
public void IsUnrecoverableConnackRejection_UnknownCode_DefaultsToRetryable()
=> MqttConnection.IsUnrecoverableConnackRejection((MqttClientConnectResultCode)250).ShouldBeFalse();
/// <summary>
/// The supervisor's own attempts go through the same CONNACK check. A broker that starts
/// refusing mid-life (credentials revoked, client banned) must stop the loop, not feed it.
/// </summary>
[Fact]
public async Task Reconnect_BrokerStartsRefusing_FaultsAndStopsTheSupervisor()
{
using var broker = new MiniBroker();
var opts = BrokerOpts(broker.Port) with { ReconnectMinBackoffSeconds = 1, ReconnectMaxBackoffSeconds = 1 };
await using var conn = new MqttConnection(opts, driverId: "t", logger: null);
var resubscribes = 0;
conn.Reconnected += _ => { Interlocked.Increment(ref resubscribes); return Task.CompletedTask; };
await conn.ConnectAsync(CancellationToken.None);
broker.ConnAckReturnCode = MiniBroker.ReturnCodeNotAuthorized; // credentials revoked
broker.DropAll();
(await WaitUntilAsync(() => conn.State == MqttConnectionState.Faulted, TimeSpan.FromSeconds(20)))
.ShouldBeTrue("a revoked-credentials reconnect must fault, not retry forever");
var acceptedAtFault = broker.AcceptedCount;
await Task.Delay(TimeSpan.FromSeconds(4), TestContext.Current.CancellationToken);
broker.AcceptedCount.ShouldBe(acceptedAtFault, "the supervisor kept retrying a hopeless rejection");
Volatile.Read(ref resubscribes).ShouldBe(0, "nothing was ever re-subscribed on a refused session");
}
/// <summary>The mirror case: a transient refusal keeps the supervisor retrying under backoff.</summary>
[Fact]
public async Task Reconnect_TransientRefusal_KeepsRetryingUnderBackoff()
{
using var broker = new MiniBroker();
var opts = BrokerOpts(broker.Port) with { ReconnectMinBackoffSeconds = 1, ReconnectMaxBackoffSeconds = 2 };
await using var conn = new MqttConnection(opts, driverId: "t", logger: null);
await conn.ConnectAsync(CancellationToken.None);
var acceptedWhileHealthy = broker.AcceptedCount;
broker.ConnAckReturnCode = MiniBroker.ReturnCodeServerUnavailable;
broker.DropAll();
(await WaitUntilAsync(() => broker.AcceptedCount >= acceptedWhileHealthy + 2, TimeSpan.FromSeconds(20)))
.ShouldBeTrue("the supervisor gave up on a transiently-unavailable broker");
conn.State.ShouldBe(MqttConnectionState.Reconnecting, "a busy broker is retryable, not Faulted");
// And it recovers on its own once the broker starts accepting again.
broker.ConnAckReturnCode = MiniBroker.ReturnCodeAccepted;
(await WaitUntilAsync(() => conn.State == MqttConnectionState.Connected, TimeSpan.FromSeconds(20)))
.ShouldBeTrue();
}
// ---------------------------------------------------------------------------------
// helpers
// ---------------------------------------------------------------------------------
@@ -1150,156 +1312,4 @@ public sealed class MqttConnectionTests
}
}
}
/// <summary>
/// The smallest broker that can complete an MQTT 3.1.1 handshake: accept TCP, answer CONNECT
/// with a success CONNACK, answer PINGREQ with PINGRESP, and nothing else. That is enough for
/// MQTTnet to report a real connection — which is what makes a real drop
/// (<see cref="DropAll"/>) drive a real <c>DisconnectedAsync</c> and a real reconnect, rather
/// than a test seam standing in for one.
/// </summary>
private sealed class MiniBroker : IDisposable
{
private static readonly byte[] ConnAckAccepted = [0x20, 0x02, 0x00, 0x00];
private static readonly byte[] PingResp = [0xD0, 0x00];
private readonly CancellationTokenSource _cts = new();
private readonly HashSet<TcpClient> _live = [];
private readonly TcpListener _listener;
private int _acceptedCount;
public MiniBroker()
{
_listener = new TcpListener(IPAddress.Loopback, 0);
_listener.Start();
Port = ((IPEndPoint)_listener.LocalEndpoint).Port;
_ = Task.Run(AcceptLoopAsync);
}
public int Port { get; }
/// <summary>When set, TCP is still accepted but CONNECT is never answered (frozen peer).</summary>
public bool Blackhole { get; set; }
/// <summary>Total TCP connections accepted since construction — counts reconnect attempts.</summary>
public int AcceptedCount => Volatile.Read(ref _acceptedCount);
/// <summary>
/// Sockets the broker still has open. Drops to zero only once the peer actually closes,
/// so a leaked-but-live client keeps this above zero.
/// </summary>
public int LiveConnections
{
get
{
lock (_live)
{
return _live.Count;
}
}
}
/// <summary>Kills every established session — the "broker went away" event.</summary>
public void DropAll()
{
lock (_live)
{
foreach (var client in _live)
{
try
{
client.Close();
}
catch (Exception)
{
// Already gone.
}
}
_live.Clear();
}
}
public void Dispose()
{
_cts.Cancel();
_listener.Stop();
DropAll();
_cts.Dispose();
}
private async Task AcceptLoopAsync()
{
try
{
while (!_cts.IsCancellationRequested)
{
var client = await _listener.AcceptTcpClientAsync(_cts.Token);
Interlocked.Increment(ref _acceptedCount);
lock (_live)
{
_live.Add(client);
}
_ = Task.Run(() => ServeAsync(client));
}
}
catch (Exception)
{
// Listener stopped / token cancelled — the fixture is going away.
}
}
private async Task ServeAsync(TcpClient client)
{
var buffer = new byte[1024];
try
{
var stream = client.GetStream();
while (!_cts.IsCancellationRequested)
{
var read = await stream.ReadAsync(buffer, _cts.Token);
if (read == 0)
{
break; // peer closed — this is how a disposed client stops counting as live
}
if (Blackhole)
{
continue;
}
var packetType = buffer[0] & 0xF0;
if (packetType == 0x10)
{
await stream.WriteAsync(ConnAckAccepted, _cts.Token);
}
else if (packetType == 0xC0)
{
await stream.WriteAsync(PingResp, _cts.Token);
}
}
}
catch (Exception)
{
// Socket torn down by either side.
}
finally
{
lock (_live)
{
_live.Remove(client);
}
try
{
client.Dispose();
}
catch (Exception)
{
// Already gone.
}
}
}
}
}
@@ -0,0 +1,99 @@
using System.Text.Json;
using MQTTnet;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
/// <summary>
/// The operator-visible half of the refused-CONNACK defect. <see cref="MqttConnectionTests"/>
/// pins that <c>MqttConnection.ConnectAsync</c> raises the rejection; this pins the thing an
/// operator actually sees — that a driver whose broker refused the CONNECT does <b>not</b> seal
/// green.
/// </summary>
/// <remarks>
/// Worth its own suite because the two failures are independent. The connection could classify a
/// rejection perfectly and the driver could still report <c>Healthy</c>, simply by not letting the
/// failure reach <c>InitializeCoreAsync</c>'s catch — which is exactly the shape the Task-13 live
/// fixture found (a wrong broker password produced <c>DriverState.Healthy</c> /
/// <c>HostState.Running</c> on a session that never authenticated).
/// </remarks>
[Trait("Category", "Unit")]
public sealed class MqttDriverConnectRejectionTests
{
/// <summary>
/// THE regression pin. A configured-wrong password must fail the deploy loudly, not produce a
/// healthy driver that will never receive a single value.
/// </summary>
[Fact]
public async Task InitializeAsync_BrokerRefusesTheCredentials_IsNotHealthy()
{
using var broker = new MiniBroker { ConnAckReturnCode = MiniBroker.ReturnCodeNotAuthorized };
var options = BrokerOptions(broker.Port);
await using var driver = new MqttDriver(options, "d", null);
var ex = await Should.ThrowAsync<MqttConnectRejectedException>(
async () => await driver.InitializeAsync(Json(options), TestContext.Current.CancellationToken));
ex.ResultCode.ShouldBe(MqttClientConnectResultCode.NotAuthorized);
var health = driver.GetHealth();
health.State.ShouldNotBe(DriverState.Healthy, "a driver the broker refused must never seal green");
health.State.ShouldBe(DriverState.Faulted);
health.LastError.ShouldNotBeNullOrWhiteSpace();
health.LastError!.ShouldContain("rejected the credentials");
}
/// <summary>
/// The transient counterpart: still a failed initialize (the driver has no session), but it must
/// not be reported as the same unrecoverable shape — a broker restarting is not a misconfigured
/// deployment.
/// </summary>
[Fact]
public async Task InitializeAsync_BrokerTemporarilyUnavailable_FailsAsTransient()
{
using var broker = new MiniBroker { ConnAckReturnCode = MiniBroker.ReturnCodeServerUnavailable };
var options = BrokerOptions(broker.Port);
await using var driver = new MqttDriver(options, "d", null);
var ex = await Should.ThrowAsync<MqttConnectRejectedException>(
async () => await driver.InitializeAsync(Json(options), TestContext.Current.CancellationToken));
ex.IsUnrecoverable.ShouldBeFalse();
driver.GetHealth().State.ShouldNotBe(DriverState.Healthy);
}
/// <summary>
/// The control. Without it, every assertion above would still hold if the driver simply never
/// went healthy against this fake broker — the suite would be green and prove nothing.
/// </summary>
[Fact]
public async Task InitializeAsync_BrokerAcceptsTheConnect_IsHealthy()
{
using var broker = new MiniBroker();
var options = BrokerOptions(broker.Port);
await using var driver = new MqttDriver(options, "d", null);
await driver.InitializeAsync(Json(options), TestContext.Current.CancellationToken);
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
}
private static MqttDriverOptions BrokerOptions(int port) => new()
{
Mode = MqttMode.Plain,
Host = "127.0.0.1",
Port = port,
UseTls = false,
ProtocolVersion = MqttProtocolVersion.V311,
Username = "svc",
Password = "pw",
KeepAliveSeconds = 300,
ConnectTimeoutSeconds = 5,
ReconnectMinBackoffSeconds = 1,
ReconnectMaxBackoffSeconds = 2,
};
private static string Json(MqttDriverOptions options) => JsonSerializer.Serialize(options, MqttJson.Options);
}