2409d05a28
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
1475 lines
69 KiB
C#
1475 lines
69 KiB
C#
using System.Buffers;
|
||
using System.Net.Security;
|
||
using System.Security.Cryptography;
|
||
using System.Security.Cryptography.X509Certificates;
|
||
using Microsoft.Extensions.Logging;
|
||
using MQTTnet;
|
||
using MQTTnet.Protocol;
|
||
|
||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
|
||
|
||
/// <summary>
|
||
/// Receives one inbound MQTT application message. Invoked on <b>MQTTnet's own dispatcher
|
||
/// thread</b>: an implementation must not block, must not do I/O and must not throw, or it stalls
|
||
/// the client's pump for every other subscription.
|
||
/// </summary>
|
||
/// <param name="topic">The concrete topic the message arrived on (never a filter).</param>
|
||
/// <param name="payload">
|
||
/// The message body. Only valid for the duration of the call — the underlying buffer belongs to
|
||
/// MQTTnet, so anything an implementation wants to keep must be copied out.
|
||
/// </param>
|
||
/// <param name="retained">
|
||
/// The message's MQTT <c>retain</c> flag as delivered to <i>this</i> client, i.e. <c>true</c> for
|
||
/// the broker's stored last-known value replayed at subscribe time, and <c>false</c> for an
|
||
/// ordinary live publish. This is the retained <b>seed</b> signal.
|
||
/// </param>
|
||
public delegate void MqttMessageObserver(string topic, ReadOnlySpan<byte> payload, bool retained);
|
||
|
||
/// <summary>One topic filter to SUBSCRIBE, as the subscription manager wants it established.</summary>
|
||
/// <param name="Topic">The topic filter (concrete, or carrying MQTT wildcards).</param>
|
||
/// <param name="Qos">Requested QoS, 0–2.</param>
|
||
/// <param name="SeedRetained">
|
||
/// Whether the broker should replay its retained message for this filter at subscribe time.
|
||
/// Honoured natively on MQTT 5.0 (retain handling); on 3.1.1 the broker always replays and the
|
||
/// manager drops the seed client-side instead.
|
||
/// </param>
|
||
public sealed record MqttTopicSubscription(string Topic, int Qos, bool SeedRetained);
|
||
|
||
/// <summary>The broker's SUBACK verdict for one requested filter.</summary>
|
||
/// <param name="Topic">The filter this outcome answers.</param>
|
||
/// <param name="Granted">Whether the broker granted the subscription.</param>
|
||
/// <param name="Reason">The broker's reason code / string, for logs and diagnostics.</param>
|
||
public sealed record MqttSubscribeOutcome(string Topic, bool Granted, string Reason);
|
||
|
||
/// <summary>
|
||
/// The one seam through which the subscription manager establishes MQTT subscriptions.
|
||
/// <see cref="MqttConnection"/> is the production implementation; the interface exists so the
|
||
/// manager's SUBACK handling (per-filter grant / rejection, total failure) is exercisable without
|
||
/// a broker.
|
||
/// </summary>
|
||
public interface IMqttSubscribeTransport
|
||
{
|
||
/// <summary>
|
||
/// Issues one SUBSCRIBE carrying every filter and returns the broker's per-filter verdict.
|
||
/// Implementations must be bounded — a broker that accepts SUBSCRIBE and never SUBACKs must
|
||
/// fail at a deadline, not hang.
|
||
/// </summary>
|
||
/// <param name="filters">The filters to establish; never empty.</param>
|
||
/// <param name="cancellationToken">Caller cancellation, linked with the implementation's deadline.</param>
|
||
/// <returns>One outcome per requested filter.</returns>
|
||
Task<IReadOnlyList<MqttSubscribeOutcome>> SubscribeAsync(
|
||
IReadOnlyList<MqttTopicSubscription> filters,
|
||
CancellationToken cancellationToken);
|
||
}
|
||
|
||
/// <summary>Lifecycle state of an <see cref="MqttConnection"/>.</summary>
|
||
public enum MqttConnectionState
|
||
{
|
||
/// <summary>Constructed, or the first connect has not yet succeeded. No supervisor is running.</summary>
|
||
Disconnected = 0,
|
||
|
||
/// <summary>An MQTT session is established.</summary>
|
||
Connected = 1,
|
||
|
||
/// <summary>
|
||
/// The session dropped and the supervisor is retrying under backoff. A broker that is merely
|
||
/// down stays here <b>indefinitely</b> — being unreachable is never a fault.
|
||
/// </summary>
|
||
Reconnecting = 2,
|
||
|
||
/// <summary>
|
||
/// Unrecoverable without a driver restart: retrying cannot help, so the supervisor has
|
||
/// stopped. Two causes — unusable configuration (a failure raised while assembling the client
|
||
/// options, before any I/O), or the supervisor itself dying on an unexpected exception. A
|
||
/// broker that is merely unreachable never lands here.
|
||
/// </summary>
|
||
Faulted = 3,
|
||
|
||
/// <summary><see cref="MqttConnection.DisposeAsync"/> has run. Terminal.</summary>
|
||
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,
|
||
/// TLS + CA pin), connects under a bounded deadline, and keeps the session alive with a
|
||
/// hand-rolled reconnect supervisor.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// <b>Connection-free construction.</b> The constructor stores configuration only — it
|
||
/// opens no socket, creates no client and starts no supervisor. The universal-browser
|
||
/// <c>CanBrowse</c> pattern constructs a throwaway instance purely to ask what driver type
|
||
/// it is, so a constructor that dialled a broker would stall the AdminUI.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>Bounded connect.</b> <see cref="ConnectAsync"/> links the caller's token with a
|
||
/// <see cref="MqttDriverOptions.ConnectTimeoutSeconds"/> deadline, and the same value is
|
||
/// fed to MQTTnet's own <see cref="MqttClientOptions.Timeout"/>. A broker that accepts the
|
||
/// TCP connection and then never answers CONNACK — the frozen-peer shape that wedged the
|
||
/// S7 read path (arch-review R2-01) — fails at the deadline instead of hanging forever.
|
||
/// Every wait in this type is bounded the same way, teardown included.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>Hand-rolled reconnect.</b> MQTTnet v5 dropped v4's <c>ManagedMqttClient</c>, so there
|
||
/// is no library-managed reconnect: this type supplies it. The supervisor starts on the
|
||
/// first <i>successful</i> connect (a failed first attempt is the caller's to retry — the
|
||
/// driver-host resilience layer re-runs <c>InitializeAsync</c> — and must not leave a
|
||
/// background task hammering an endpoint the caller has given up on). MQTTnet's
|
||
/// <c>DisconnectedAsync</c> callback only signals a semaphore and returns, so the library's
|
||
/// own dispatcher thread is never blocked by a backoff sleep.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b><see cref="Reconnected"/> is load-bearing.</b> MQTT subscriptions do not survive a
|
||
/// clean session, and a reconnect that completes without re-subscribing produces a
|
||
/// healthy-looking connection that receives nothing, forever, with no error, no exception
|
||
/// and no bad status. So the callback fires on <b>every</b> successful reconnect — including
|
||
/// persistent-session reconnects, where re-subscribing is merely redundant. Re-subscribing
|
||
/// an already-subscribed topic is harmless; missing one is silent death. If the callback
|
||
/// fails, the freshly-established session is torn down and retried rather than left
|
||
/// connected-but-deaf. It is invoked under a cancellation token and a
|
||
/// <see cref="MqttDriverOptions.ConnectTimeoutSeconds"/> ceiling: a subscriber that hangs on
|
||
/// a broker which accepts SUBSCRIBE and never SUBACKs — the frozen-peer shape again — must
|
||
/// not be able to park the supervisor or outlive <see cref="DisposeAsync"/>.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>Connect is idempotent.</b> MQTTnet throws (and raises no <c>DisconnectedAsync</c>) if
|
||
/// asked to connect a client that is already connected, so both connect paths check first and
|
||
/// return a no-op. The two paths genuinely race: the supervisor is told to expect the driver
|
||
/// host to re-run <c>InitializeAsync</c>, so a caller can restore the session while the
|
||
/// supervisor sits in backoff, and the supervisor can restore it a moment before a caller
|
||
/// asks. Without the check, the first case leaves the supervisor failing forever against a
|
||
/// perfectly healthy connection (inflating <c>attempt</c>, so the <i>next</i> genuine outage
|
||
/// waits <see cref="MqttDriverOptions.ReconnectMaxBackoffSeconds"/> instead of min) and the
|
||
/// second throws an undocumented <see cref="InvalidOperationException"/> out of
|
||
/// <see cref="ConnectAsync"/> on a working session. When the supervisor finds the session
|
||
/// already restored it stops retrying <b>without</b> firing <see cref="Reconnected"/> — the
|
||
/// caller that reconnected owns its own subscribe, per the <see cref="ConnectAsync"/>
|
||
/// contract.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>State transitions.</b> <see cref="MqttConnectionState.Disposed"/> is terminal; nothing
|
||
/// moves off it.
|
||
/// <list type="table">
|
||
/// <item>
|
||
/// <term><see cref="MqttConnectionState.Connected"/></term>
|
||
/// <description>
|
||
/// set by <see cref="ConnectAsync"/> on success (or on finding the session
|
||
/// already up), and by the supervisor <i>only after</i>
|
||
/// <see cref="Reconnected"/> has completed — so on the reconnect path
|
||
/// <c>Connected</c> means "connected <i>and</i> re-subscribed".
|
||
/// </description>
|
||
/// </item>
|
||
/// <item>
|
||
/// <term><see cref="MqttConnectionState.Reconnecting"/></term>
|
||
/// <description>
|
||
/// set by the <c>DisconnectedAsync</c> callback when a live session drops, and
|
||
/// held by the supervisor for the whole retry + re-subscribe sequence.
|
||
/// </description>
|
||
/// </item>
|
||
/// <item>
|
||
/// <term><see cref="MqttConnectionState.Faulted"/></term>
|
||
/// <description>
|
||
/// set when the client options cannot be assembled, or when the supervisor exits
|
||
/// on an unexpected exception. Both mean no further recovery without a restart.
|
||
/// </description>
|
||
/// </item>
|
||
/// <item>
|
||
/// <term><see cref="MqttConnectionState.Disposed"/></term>
|
||
/// <description>set first thing in <see cref="DisposeAsync"/>.</description>
|
||
/// </item>
|
||
/// </list>
|
||
/// <see cref="State"/> and <see cref="IsConnected"/> can legitimately disagree in <b>both</b>
|
||
/// directions and are not interchangeable: <c>Reconnecting</c> with
|
||
/// <c>IsConnected == true</c> is the window where the socket is up but the re-subscribe has
|
||
/// not finished, and <c>Connected</c> with <c>IsConnected == false</c> is the instant between
|
||
/// the socket dropping and MQTTnet raising its callback. <see cref="State"/> is the health
|
||
/// surface; <see cref="IsConnected"/> is the transport fact.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>Credentials never leak.</b> Nothing here logs or formats
|
||
/// <see cref="MqttDriverOptions.Password"/>; the options record itself redacts it in
|
||
/// <c>ToString()</c>.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>Lifecycle is serialised.</b> Connect (caller-initiated or supervisor-initiated) and
|
||
/// dispose run under a single gate, and a connect that completes re-checks the disposed flag
|
||
/// <i>while still holding that gate</i>. This closes the connection leak the Task-3 review
|
||
/// traced: dispose could observe a still-null client, dispose nothing and return, after
|
||
/// which the in-flight connect assigned a client and connected successfully — leaving a live
|
||
/// socket no later dispose could ever reach. Now the losing side of that race disposes the
|
||
/// client it created and reports <see cref="ObjectDisposedException"/>.
|
||
/// </para>
|
||
/// Sparkplug rebirth-on-reconnect (Task 21, which hangs off <see cref="Reconnected"/>) is
|
||
/// deliberately not implemented here.
|
||
/// </remarks>
|
||
public sealed class MqttConnection : IAsyncDisposable, IMqttSubscribeTransport
|
||
{
|
||
private readonly string _driverId;
|
||
|
||
/// <summary>
|
||
/// Serialises connect (both callers of it) against dispose. Never disposed: a late MQTTnet
|
||
/// callback can still reach this instance after teardown, and a disposed
|
||
/// <see cref="SemaphoreSlim"/> would throw inside the library's own dispatcher.
|
||
/// </summary>
|
||
private readonly SemaphoreSlim _lifecycleGate = new(1, 1);
|
||
|
||
/// <summary>
|
||
/// Cancelled by <see cref="DisposeAsync"/>; aborts the supervisor, any in-flight connect and
|
||
/// any in-flight <see cref="Reconnected"/> callback. Never disposed — deliberately, like the
|
||
/// two semaphores: a connect racing dispose reads <see cref="CancellationTokenSource.Token"/>
|
||
/// after its own disposed check, and disposing the source would turn that benign loser into an
|
||
/// <see cref="ObjectDisposedException"/> naming the wrong type. It holds no timer and no
|
||
/// surviving registrations (every linked source here is <c>using</c>-scoped), so leaving it
|
||
/// undisposed costs nothing.
|
||
/// </summary>
|
||
private readonly CancellationTokenSource _lifetimeCts = new();
|
||
|
||
private readonly ILogger? _logger;
|
||
private readonly MqttDriverOptions _options;
|
||
|
||
/// <summary>
|
||
/// Wakes the supervisor. Unbounded max count: spurious releases (a failed connect attempt
|
||
/// also raises <c>DisconnectedAsync</c>) are drained harmlessly by the still-connected guard,
|
||
/// whereas a bounded semaphore would throw on the extra release. Never disposed, for the same
|
||
/// reason as <see cref="_lifecycleGate"/>.
|
||
/// </summary>
|
||
private readonly SemaphoreSlim _reconnectWake = new(0);
|
||
|
||
private IMqttClient? _client;
|
||
private int _disposed;
|
||
private long _lastMessageTicksUtc;
|
||
private Task? _reconnectWorker;
|
||
private int _state = (int)MqttConnectionState.Disconnected;
|
||
|
||
/// <summary>Stores configuration only — no network, no client instantiation, no supervisor.</summary>
|
||
/// <param name="options">Broker connection settings.</param>
|
||
/// <param name="driverId">Driver instance id, used only for log/diagnostic correlation.</param>
|
||
/// <param name="logger">Optional logger; never receives credentials.</param>
|
||
public MqttConnection(MqttDriverOptions options, string driverId, ILogger? logger = null)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(options);
|
||
ArgumentException.ThrowIfNullOrWhiteSpace(driverId);
|
||
|
||
_options = options;
|
||
_driverId = driverId;
|
||
_logger = logger;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Raised after every successful <i>re</i>connect (never after the initial
|
||
/// <see cref="ConnectAsync"/>, which the caller already sequences its own subscribe behind,
|
||
/// and never when the supervisor merely finds the session already restored by such a caller).
|
||
/// Subscribers must re-establish their MQTT subscriptions here — see the type remarks for
|
||
/// why skipping it is silent death. Every subscriber is invoked even if an earlier one
|
||
/// throws; a throwing subscriber causes the session to be torn down and retried.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// The supplied token is cancelled by <see cref="DisposeAsync"/>, and the whole fan-out is
|
||
/// additionally capped at <see cref="MqttDriverOptions.ConnectTimeoutSeconds"/> — a subscriber
|
||
/// that ignores its token cannot park the supervisor or make it outlive teardown. Subscribers
|
||
/// should flow the token into their own network calls rather than relying on that ceiling,
|
||
/// which abandons rather than stops the offending work.
|
||
/// </remarks>
|
||
public event Func<CancellationToken, Task>? Reconnected;
|
||
|
||
/// <summary>
|
||
/// Raised for every inbound application message, on MQTTnet's own dispatcher thread. This
|
||
/// connection does no routing, parsing or typing of its own — that is the subscription
|
||
/// manager's job; here the message is only stamped onto <see cref="LastMessageUtc"/> and
|
||
/// handed on.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Handlers must not block, do I/O or throw. A throwing handler is caught and logged here
|
||
/// rather than being allowed to escape into the library's pump — one bad tag must not stop
|
||
/// delivery for every other subscriber.
|
||
/// </remarks>
|
||
public event MqttMessageObserver? MessageReceived;
|
||
|
||
/// <summary>Whether the underlying client currently holds an established MQTT session.</summary>
|
||
public bool IsConnected => _client?.IsConnected ?? false;
|
||
|
||
/// <summary>
|
||
/// UTC timestamp of the most recent inbound application message, or <c>null</c> if none has
|
||
/// arrived on this instance. Connection health only — nothing here routes or parses the
|
||
/// message; that is the subscription manager's job (Task 6).
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// The plan calls this member <c>LastMessageAgeUtc</c>, which conflates a timestamp with an
|
||
/// age; it is split here into the instant (<see cref="LastMessageUtc"/>) and the elapsed span
|
||
/// (<see cref="LastMessageAge"/>).
|
||
/// </remarks>
|
||
public DateTime? LastMessageUtc
|
||
{
|
||
get
|
||
{
|
||
var ticks = Interlocked.Read(ref _lastMessageTicksUtc);
|
||
return ticks == 0 ? null : new DateTime(ticks, DateTimeKind.Utc);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// How long ago the most recent inbound application message arrived, or <c>null</c> if none
|
||
/// has. A connection that is <see cref="MqttConnectionState.Connected"/> with a large age is
|
||
/// the "reconnected but never re-subscribed" shape.
|
||
/// </summary>
|
||
public TimeSpan? LastMessageAge => LastMessageUtc is { } at ? DateTime.UtcNow - at : null;
|
||
|
||
/// <summary>Current lifecycle state. See <see cref="MqttConnectionState"/>.</summary>
|
||
public MqttConnectionState State => (MqttConnectionState)Volatile.Read(ref _state);
|
||
|
||
/// <summary>
|
||
/// Test seam: awaited inside the gated connect immediately after the broker session is
|
||
/// established and <b>before</b> the disposed re-check, so a test can reproduce the exact
|
||
/// connect/dispose interleaving that used to leak a live connection. Always <c>null</c> in
|
||
/// production.
|
||
/// </summary>
|
||
internal Func<Task>? AfterConnectHookForTests { get; set; }
|
||
|
||
private bool Disposed => Volatile.Read(ref _disposed) != 0;
|
||
|
||
/// <inheritdoc/>
|
||
public async ValueTask DisposeAsync()
|
||
{
|
||
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
||
{
|
||
return;
|
||
}
|
||
|
||
SetState(MqttConnectionState.Disposed);
|
||
|
||
// Stop the supervisor and abort any in-flight connect FIRST, so the gate below is not held
|
||
// for a full connect deadline by work that is already pointless.
|
||
try
|
||
{
|
||
await _lifetimeCts.CancelAsync().ConfigureAwait(false);
|
||
}
|
||
catch (ObjectDisposedException)
|
||
{
|
||
// Nothing to cancel.
|
||
}
|
||
|
||
var worker = Volatile.Read(ref _reconnectWorker);
|
||
if (worker is not null)
|
||
{
|
||
try
|
||
{
|
||
await worker.WaitAsync(TimeSpan.FromSeconds(_options.ConnectTimeoutSeconds)).ConfigureAwait(false);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger?.LogDebug(ex, "MQTT driver '{DriverId}': reconnect supervisor did not stop cleanly.", _driverId);
|
||
}
|
||
}
|
||
|
||
// Bounded even here: a wedged connect must not turn dispose into a hang. If the gate cannot
|
||
// be taken we still claim the client — double-disposing an MqttClient is harmless, leaking a
|
||
// live one is not.
|
||
var gated = await _lifecycleGate
|
||
.WaitAsync(TimeSpan.FromSeconds(_options.ConnectTimeoutSeconds))
|
||
.ConfigureAwait(false);
|
||
if (!gated)
|
||
{
|
||
_logger?.LogWarning(
|
||
"MQTT driver '{DriverId}': lifecycle gate still held at dispose; tearing the client down anyway.",
|
||
_driverId);
|
||
}
|
||
|
||
try
|
||
{
|
||
var client = Interlocked.Exchange(ref _client, null);
|
||
if (client is not null)
|
||
{
|
||
await CloseAsync(client).ConfigureAwait(false);
|
||
}
|
||
}
|
||
finally
|
||
{
|
||
if (gated)
|
||
{
|
||
_lifecycleGate.Release();
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// The backoff a supervisor waits before reconnect attempt <paramref name="attempt"/>:
|
||
/// <c>minSeconds × 2^attempt</c>, saturated at <paramref name="maxSeconds"/>. Pure — no
|
||
/// clock, no state, no jitter (deliberately: the schedule is asserted by exact equality, and
|
||
/// with one connection per driver instance there is no thundering herd to spread out).
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Computed in <see cref="double"/> rather than by shifting, because <c>min << attempt</c>
|
||
/// wraps — <c>1 << 32</c> is <c>1</c> — which would silently turn a long outage back
|
||
/// into a once-a-second hammering of a dead broker. A <c>maxSeconds</c> below
|
||
/// <paramref name="minSeconds"/> is misconfiguration; the <i>floor</i> wins, because the floor
|
||
/// is what stops the loop going hot.
|
||
/// </remarks>
|
||
/// <param name="attempt">Zero-based attempt index; negatives are treated as the first attempt.</param>
|
||
/// <param name="minSeconds">Delay before the first attempt, and the floor for every later one.</param>
|
||
/// <param name="maxSeconds">Cap on the exponential growth.</param>
|
||
public static TimeSpan NextBackoff(int attempt, int minSeconds, int maxSeconds)
|
||
{
|
||
var min = Math.Max(1d, minSeconds);
|
||
var max = Math.Max(min, maxSeconds);
|
||
|
||
if (attempt <= 0)
|
||
{
|
||
return TimeSpan.FromSeconds(min);
|
||
}
|
||
|
||
// Math.Pow saturates to +Infinity instead of wrapping, so a huge attempt count clamps to max.
|
||
var seconds = min * Math.Pow(2d, attempt);
|
||
return TimeSpan.FromSeconds(seconds >= max || double.IsNaN(seconds) ? max : seconds);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Connects to the broker, failing at <see cref="MqttDriverOptions.ConnectTimeoutSeconds"/>
|
||
/// rather than waiting indefinitely on an unresponsive peer. On success the reconnect
|
||
/// supervisor takes over keeping the session alive.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// May be retried on the same instance after a failed attempt — the underlying
|
||
/// <see cref="IMqttClient"/> is created once and reused across attempts. Safe to call
|
||
/// concurrently with <see cref="DisposeAsync"/>: the two are serialised, and a connect
|
||
/// that loses the race disposes whatever it built and throws
|
||
/// <see cref="ObjectDisposedException"/>.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>Idempotent.</b> Calling it on a session that is already established — including one
|
||
/// the reconnect supervisor restored a moment earlier — is a no-op that returns normally,
|
||
/// not the <see cref="InvalidOperationException"/> MQTTnet would raise for a
|
||
/// connect-while-connected. The caller still owns re-establishing its own subscriptions
|
||
/// after this returns; <see cref="Reconnected"/> is not fired for this path.
|
||
/// </para>
|
||
/// </remarks>
|
||
/// <param name="cancellationToken">Caller cancellation; linked with the connect deadline.</param>
|
||
/// <exception cref="TimeoutException">The connect deadline elapsed.</exception>
|
||
/// <exception cref="OperationCanceledException"><paramref name="cancellationToken"/> was cancelled.</exception>
|
||
/// <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);
|
||
|
||
await ConnectCoreAsync(supervisorAttempt: false, cancellationToken).ConfigureAwait(false);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Invokes every <see cref="Reconnected"/> subscriber. Walks the invocation list explicitly:
|
||
/// awaiting a multicast delegate directly yields only the <i>last</i> subscriber's task and
|
||
/// abandons the earlier ones, so a single throwing subscriber would silently skip every
|
||
/// subscriber behind it — and a skipped re-subscribe is a topic that goes dark. Failures are
|
||
/// collected and rethrown after every subscriber has had its turn.
|
||
/// </summary>
|
||
/// <param name="cancellationToken">Passed to every subscriber; cancelled by <see cref="DisposeAsync"/>.</param>
|
||
internal async Task FireReconnectedAsync(CancellationToken cancellationToken)
|
||
{
|
||
var subscribers = Reconnected;
|
||
if (subscribers is null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
List<Exception>? failures = null;
|
||
foreach (var subscriber in subscribers.GetInvocationList().Cast<Func<CancellationToken, Task>>())
|
||
{
|
||
try
|
||
{
|
||
await subscriber(cancellationToken).ConfigureAwait(false);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
(failures ??= []).Add(ex);
|
||
}
|
||
}
|
||
|
||
if (failures is not null)
|
||
{
|
||
throw new AggregateException(
|
||
$"MQTT driver '{_driverId}': {failures.Count} re-subscribe callback(s) failed after reconnect.",
|
||
failures);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// The single gated connect both entry points funnel through.
|
||
/// </summary>
|
||
/// <param name="supervisorAttempt">
|
||
/// <c>true</c> when the reconnect supervisor is calling. It suppresses publishing
|
||
/// <see cref="MqttConnectionState.Connected"/> on success, because on the reconnect path
|
||
/// "connected" is only true once <see cref="Reconnected"/> has re-subscribed — publishing it
|
||
/// here would advertise a healthy connection during the window in which it has no
|
||
/// subscriptions at all. It also suppresses starting the supervisor (it is the supervisor).
|
||
/// </param>
|
||
/// <param name="cancellationToken">Caller cancellation; linked with the connect deadline.</param>
|
||
/// <returns>
|
||
/// <c>true</c> if this call established the session; <c>false</c> if it found one already
|
||
/// established and did nothing. The supervisor uses <c>false</c> to stop retrying <i>without</i>
|
||
/// firing <see cref="Reconnected"/> — whoever established that session owns its subscribe.
|
||
/// </returns>
|
||
private async Task<bool> ConnectCoreAsync(bool supervisorAttempt, CancellationToken cancellationToken)
|
||
{
|
||
// The options are rebuilt per attempt so a rotated CA file is picked up on the next connect
|
||
// rather than being pinned for the life of the process. This is also the only step that can
|
||
// fail unrecoverably — it is pure config assembly, so retrying it can never help.
|
||
MqttClientOptions clientOptions;
|
||
try
|
||
{
|
||
clientOptions = BuildClientOptions(_options, clientIdSuffix: null, _logger);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
SetState(MqttConnectionState.Faulted);
|
||
_logger?.LogError(
|
||
ex,
|
||
"MQTT driver '{DriverId}': broker configuration is unusable; no amount of retrying will help.",
|
||
_driverId);
|
||
throw;
|
||
}
|
||
|
||
using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(_options.ConnectTimeoutSeconds));
|
||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(
|
||
cancellationToken,
|
||
deadline.Token,
|
||
_lifetimeCts.Token);
|
||
|
||
_logger?.LogDebug(
|
||
"MQTT driver '{DriverId}': connecting to {Host}:{Port} (tls={UseTls}, timeout={TimeoutSeconds}s).",
|
||
_driverId,
|
||
_options.Host,
|
||
_options.Port,
|
||
_options.UseTls,
|
||
_options.ConnectTimeoutSeconds);
|
||
|
||
try
|
||
{
|
||
await _lifecycleGate.WaitAsync(linked.Token).ConfigureAwait(false);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
throw Classify(ex, cancellationToken, deadline);
|
||
}
|
||
|
||
try
|
||
{
|
||
// Dispose won the race outright: it has already flagged the instance and disposed
|
||
// whatever existed. Creating a client now would be creating one nobody can reach.
|
||
ObjectDisposedException.ThrowIf(Disposed, this);
|
||
|
||
var client = _client;
|
||
if (client is null)
|
||
{
|
||
client = new MqttClientFactory().CreateMqttClient();
|
||
AttachHandlers(client);
|
||
_client = client;
|
||
}
|
||
|
||
// Idempotence, and the whole point of the gate: MQTTnet throws
|
||
// "not allowed to connect with a server after the connection is established" for a
|
||
// connect-while-connected AND raises no DisconnectedAsync for it, so neither the retry
|
||
// loop nor Classify would ever recover. Both entry points race for real — see the type
|
||
// remarks — so whichever arrives second must find the session and leave it alone.
|
||
if (client.IsConnected)
|
||
{
|
||
SetState(MqttConnectionState.Connected);
|
||
StartSupervisorIfCallerConnect(supervisorAttempt);
|
||
_logger?.LogDebug(
|
||
"MQTT driver '{DriverId}': connect to {Host}:{Port} skipped — the session is already established.",
|
||
_driverId,
|
||
_options.Host,
|
||
_options.Port);
|
||
return 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)
|
||
{
|
||
await hook().ConfigureAwait(false);
|
||
}
|
||
|
||
// The race the Task-3 review traced: dispose flagged the instance while this connect was
|
||
// in flight, then blocked on the very gate this call holds — so it disposed nothing and
|
||
// the session below would have been unreachable forever. Hand it back here instead.
|
||
if (Disposed)
|
||
{
|
||
Interlocked.CompareExchange(ref _client, null, client);
|
||
await CloseAsync(client).ConfigureAwait(false);
|
||
throw new ObjectDisposedException(
|
||
nameof(MqttConnection),
|
||
$"MQTT driver '{_driverId}': the connection was disposed while a connect to "
|
||
+ $"{_options.Host}:{_options.Port} was in flight; the established session was torn down.");
|
||
}
|
||
|
||
// On the supervisor path the session is up but has no subscriptions yet, so State stays
|
||
// Reconnecting until FireReconnectedAsync succeeds — see the state table in the remarks.
|
||
if (!supervisorAttempt)
|
||
{
|
||
SetState(MqttConnectionState.Connected);
|
||
}
|
||
|
||
StartSupervisorIfCallerConnect(supervisorAttempt);
|
||
return true;
|
||
}
|
||
catch (ObjectDisposedException)
|
||
{
|
||
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);
|
||
}
|
||
finally
|
||
{
|
||
_lifecycleGate.Release();
|
||
}
|
||
}
|
||
|
||
/// <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.
|
||
/// </summary>
|
||
private void StartSupervisorIfCallerConnect(bool supervisorAttempt)
|
||
{
|
||
if (!supervisorAttempt)
|
||
{
|
||
_reconnectWorker ??= Task.Run(() => ReconnectSupervisorAsync(_lifetimeCts.Token));
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Maps a bounded-operation failure onto this type's documented contract. MQTTnet wraps a
|
||
/// cancelled connect in <c>MqttConnectingFailedException</c> rather than letting the
|
||
/// <see cref="OperationCanceledException"/> surface, so the legs are told apart by which
|
||
/// token fired, not by exception type. Precedence: teardown, then caller intent, then the
|
||
/// deadline.
|
||
/// </summary>
|
||
/// <param name="ex">The failure to classify.</param>
|
||
/// <param name="cancellationToken">The caller's token — cancelled means caller intent.</param>
|
||
/// <param name="deadline">The operation's own deadline source.</param>
|
||
/// <param name="operation">The operation name for the message ("connect", "subscribe").</param>
|
||
private Exception Classify(
|
||
Exception ex,
|
||
CancellationToken cancellationToken,
|
||
CancellationTokenSource deadline,
|
||
string operation = "connect")
|
||
{
|
||
if (Disposed)
|
||
{
|
||
return new ObjectDisposedException(
|
||
nameof(MqttConnection),
|
||
new InvalidOperationException(
|
||
$"MQTT driver '{_driverId}': {operation} to {_options.Host}:{_options.Port} was aborted because the "
|
||
+ "connection was disposed while the attempt was in flight.",
|
||
ex));
|
||
}
|
||
|
||
if (cancellationToken.IsCancellationRequested)
|
||
{
|
||
return new OperationCanceledException(
|
||
$"MQTT driver '{_driverId}': {operation} to {_options.Host}:{_options.Port} was cancelled.",
|
||
ex,
|
||
cancellationToken);
|
||
}
|
||
|
||
if (deadline.IsCancellationRequested)
|
||
{
|
||
return new TimeoutException(
|
||
$"MQTT driver '{_driverId}': {operation} to {_options.Host}:{_options.Port} did not complete within "
|
||
+ $"{_options.ConnectTimeoutSeconds}s.",
|
||
ex);
|
||
}
|
||
|
||
return ex;
|
||
}
|
||
|
||
private void AttachHandlers(IMqttClient client)
|
||
{
|
||
client.DisconnectedAsync += OnDisconnectedAsync;
|
||
client.ApplicationMessageReceivedAsync += OnApplicationMessageReceivedAsync;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Runs on MQTTnet's own dispatcher, so it does exactly two non-blocking things and returns.
|
||
/// Sleeping the backoff here would stall the client's internal pump.
|
||
/// </summary>
|
||
private Task OnDisconnectedAsync(MqttClientDisconnectedEventArgs args)
|
||
{
|
||
if (Disposed || State is MqttConnectionState.Faulted or MqttConnectionState.Disposed)
|
||
{
|
||
return Task.CompletedTask;
|
||
}
|
||
|
||
if (State == MqttConnectionState.Connected)
|
||
{
|
||
SetState(MqttConnectionState.Reconnecting);
|
||
}
|
||
|
||
// Released unconditionally rather than only when the client had been connected: a missed
|
||
// wake is a permanently dark connection, whereas a spurious one costs a loop iteration.
|
||
_reconnectWake.Release();
|
||
return Task.CompletedTask;
|
||
}
|
||
|
||
private Task OnApplicationMessageReceivedAsync(MqttApplicationMessageReceivedEventArgs args)
|
||
{
|
||
Interlocked.Exchange(ref _lastMessageTicksUtc, DateTime.UtcNow.Ticks);
|
||
|
||
var observers = MessageReceived;
|
||
if (observers is null)
|
||
{
|
||
return Task.CompletedTask;
|
||
}
|
||
|
||
var message = args.ApplicationMessage;
|
||
var payload = message.Payload;
|
||
|
||
// Same idiom the browse session uses: the common single-segment case is served straight off
|
||
// the library's buffer; a fragmented sequence is flattened once. Either way the span is only
|
||
// valid for this call, which is exactly what MqttMessageObserver documents.
|
||
ReadOnlySpan<byte> body = payload.IsSingleSegment ? payload.FirstSpan : payload.ToArray();
|
||
|
||
try
|
||
{
|
||
observers(message.Topic, body, message.Retain);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
// An exception escaping here surfaces inside MQTTnet's own pump. Contain it: a broken
|
||
// observer must degrade its own tags, never stop delivery for every other subscription.
|
||
_logger?.LogError(
|
||
ex,
|
||
"MQTT driver '{DriverId}': an inbound-message observer threw; the message was dropped.",
|
||
_driverId);
|
||
}
|
||
|
||
return Task.CompletedTask;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Issues one SUBSCRIBE carrying every requested filter, bounded by
|
||
/// <see cref="MqttDriverOptions.ConnectTimeoutSeconds"/> so a broker that accepts SUBSCRIBE and
|
||
/// never answers SUBACK — the frozen-peer shape again — fails at the deadline instead of
|
||
/// parking the caller (and, on the reconnect path, the supervisor) forever.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// The deadline is this method's own linked source, deliberately <b>not</b>
|
||
/// <see cref="MqttClientOptions.Timeout"/>: that knob already governs every MQTTnet operation
|
||
/// and repurposing it would couple the subscribe budget to the connect budget in a way neither
|
||
/// side could change independently.
|
||
/// <para>
|
||
/// A rejected filter is <b>not</b> an exception: the returned outcomes carry the broker's
|
||
/// per-filter verdict so the caller can degrade exactly the affected references. Only a
|
||
/// failure of the SUBSCRIBE itself (transport error, deadline, teardown) throws.
|
||
/// </para>
|
||
/// </remarks>
|
||
/// <param name="filters">The filters to establish.</param>
|
||
/// <param name="cancellationToken">Caller cancellation; linked with the subscribe deadline.</param>
|
||
/// <returns>One outcome per requested filter, in request order.</returns>
|
||
/// <exception cref="TimeoutException">The subscribe deadline elapsed.</exception>
|
||
/// <exception cref="OperationCanceledException"><paramref name="cancellationToken"/> was cancelled.</exception>
|
||
/// <exception cref="ObjectDisposedException">The connection was disposed.</exception>
|
||
/// <exception cref="InvalidOperationException">There is no established session to subscribe on.</exception>
|
||
public async Task<IReadOnlyList<MqttSubscribeOutcome>> SubscribeAsync(
|
||
IReadOnlyList<MqttTopicSubscription> filters,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(filters);
|
||
ObjectDisposedException.ThrowIf(Disposed, this);
|
||
|
||
if (filters.Count == 0)
|
||
{
|
||
return [];
|
||
}
|
||
|
||
var client = _client
|
||
?? throw new InvalidOperationException(
|
||
$"MQTT driver '{_driverId}': cannot subscribe before a session to {_options.Host}:{_options.Port} "
|
||
+ "has been established.");
|
||
|
||
var builder = new MqttClientSubscribeOptionsBuilder();
|
||
foreach (var filter in filters)
|
||
{
|
||
builder = builder.WithTopicFilter(f => f
|
||
.WithTopic(filter.Topic)
|
||
.WithQualityOfServiceLevel(MapQos(filter.Qos))
|
||
// Only meaningful on MQTT 5.0. On 3.1.1 the broker always replays its retained
|
||
// message, so the manager also drops unwanted seeds client-side off the retain flag.
|
||
.WithRetainHandling(filter.SeedRetained
|
||
? MqttRetainHandling.SendAtSubscribe
|
||
: MqttRetainHandling.DoNotSendOnSubscribe));
|
||
}
|
||
|
||
using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(_options.ConnectTimeoutSeconds));
|
||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(
|
||
cancellationToken,
|
||
deadline.Token,
|
||
_lifetimeCts.Token);
|
||
|
||
MqttClientSubscribeResult result;
|
||
try
|
||
{
|
||
result = await client.SubscribeAsync(builder.Build(), linked.Token).ConfigureAwait(false);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
throw Classify(ex, cancellationToken, deadline, operation: "subscribe");
|
||
}
|
||
|
||
// Pair each requested filter with its SUBACK item positionally: MQTT guarantees SUBACK reason
|
||
// codes arrive in the order of the SUBSCRIBE's filters. A short/absent SUBACK (a
|
||
// specification-violating broker) is reported as ungranted rather than silently assumed good.
|
||
var items = result.Items as IList<MqttClientSubscribeResultItem> ?? [.. result.Items];
|
||
var outcomes = new List<MqttSubscribeOutcome>(filters.Count);
|
||
for (var i = 0; i < filters.Count; i++)
|
||
{
|
||
if (i >= items.Count)
|
||
{
|
||
outcomes.Add(new MqttSubscribeOutcome(filters[i].Topic, Granted: false, "NoSubAckReasonCode"));
|
||
continue;
|
||
}
|
||
|
||
var code = items[i].ResultCode;
|
||
var granted = code is MqttClientSubscribeResultCode.GrantedQoS0
|
||
or MqttClientSubscribeResultCode.GrantedQoS1
|
||
or MqttClientSubscribeResultCode.GrantedQoS2;
|
||
outcomes.Add(new MqttSubscribeOutcome(filters[i].Topic, granted, code.ToString()));
|
||
}
|
||
|
||
return outcomes;
|
||
}
|
||
|
||
/// <summary>Maps a configured QoS integer onto MQTTnet's enum, clamping an out-of-range value.</summary>
|
||
private static MqttQualityOfServiceLevel MapQos(int qos) => qos switch
|
||
{
|
||
<= 0 => MqttQualityOfServiceLevel.AtMostOnce,
|
||
1 => MqttQualityOfServiceLevel.AtLeastOnce,
|
||
_ => MqttQualityOfServiceLevel.ExactlyOnce,
|
||
};
|
||
|
||
/// <summary>
|
||
/// The reconnect loop MQTTnet v5 no longer provides. Started on the first successful connect
|
||
/// and stopped by <see cref="DisposeAsync"/> cancelling the lifetime token; it owns every
|
||
/// reconnect attempt so the library's dispatcher never waits on one.
|
||
/// </summary>
|
||
private async Task ReconnectSupervisorAsync(CancellationToken cancellationToken)
|
||
{
|
||
try
|
||
{
|
||
while (!cancellationToken.IsCancellationRequested)
|
||
{
|
||
await _reconnectWake.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||
|
||
if (Disposed || State == MqttConnectionState.Faulted)
|
||
{
|
||
return;
|
||
}
|
||
|
||
if (IsConnected)
|
||
{
|
||
continue; // Spurious wake (e.g. a failed attempt's own disconnect callback).
|
||
}
|
||
|
||
SetState(MqttConnectionState.Reconnecting);
|
||
|
||
for (var attempt = 0; !cancellationToken.IsCancellationRequested && !Disposed; attempt++)
|
||
{
|
||
// A caller's ConnectAsync may have restored the session while we slept. Bail
|
||
// before burning the backoff, not just before burning an attempt.
|
||
if (IsConnected)
|
||
{
|
||
AdoptSessionRestoredByCaller();
|
||
break;
|
||
}
|
||
|
||
await Task
|
||
.Delay(
|
||
NextBackoff(
|
||
attempt,
|
||
_options.ReconnectMinBackoffSeconds,
|
||
_options.ReconnectMaxBackoffSeconds),
|
||
cancellationToken)
|
||
.ConfigureAwait(false);
|
||
|
||
bool established;
|
||
try
|
||
{
|
||
established = await ConnectCoreAsync(supervisorAttempt: true, cancellationToken)
|
||
.ConfigureAwait(false);
|
||
}
|
||
catch (ObjectDisposedException) when (Disposed)
|
||
{
|
||
return;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
if (State == MqttConnectionState.Faulted)
|
||
{
|
||
_logger?.LogError(
|
||
ex,
|
||
"MQTT driver '{DriverId}': reconnect abandoned — the broker configuration is unusable.",
|
||
_driverId);
|
||
return;
|
||
}
|
||
|
||
_logger?.LogDebug(
|
||
ex,
|
||
"MQTT driver '{DriverId}': reconnect attempt {Attempt} to {Host}:{Port} failed.",
|
||
_driverId,
|
||
attempt + 1,
|
||
_options.Host,
|
||
_options.Port);
|
||
continue;
|
||
}
|
||
|
||
if (!established)
|
||
{
|
||
// Someone else restored the session between the backoff and the connect.
|
||
// Firing Reconnected here would re-subscribe on top of a caller that is about
|
||
// to do exactly that itself, and — worse — leaving the loop running would
|
||
// keep failing against a healthy connection forever.
|
||
AdoptSessionRestoredByCaller();
|
||
break;
|
||
}
|
||
|
||
if (!await TryReSubscribeAsync(cancellationToken).ConfigureAwait(false))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
// Only now is "Connected" true in the sense this type sells it: connected AND
|
||
// subscribed.
|
||
SetState(MqttConnectionState.Connected);
|
||
_logger?.LogInformation(
|
||
"MQTT driver '{DriverId}': reconnected to {Host}:{Port} after {Attempts} attempt(s); "
|
||
+ "subscriptions re-established.",
|
||
_driverId,
|
||
_options.Host,
|
||
_options.Port,
|
||
attempt + 1);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
// Dispose cancelled the lifetime token — the expected way this loop ends.
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
// Reaching here means the connection is permanently dark, which is exactly the failure
|
||
// this supervisor exists to prevent — it must never be swallowed quietly, and State must
|
||
// not keep reporting Reconnecting, which every consumer reads as "recovering".
|
||
SetState(MqttConnectionState.Faulted);
|
||
_logger?.LogError(
|
||
ex,
|
||
"MQTT driver '{DriverId}': reconnect supervisor stopped unexpectedly; the broker session will not "
|
||
+ "recover without a driver restart.",
|
||
_driverId);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// The session came back without this supervisor's help — a caller's <see cref="ConnectAsync"/>
|
||
/// won the race while we were in backoff. Publish the truth and stand down; deliberately does
|
||
/// <b>not</b> fire <see cref="Reconnected"/>, because that caller owns its own re-subscribe.
|
||
/// </summary>
|
||
private void AdoptSessionRestoredByCaller()
|
||
{
|
||
SetState(MqttConnectionState.Connected);
|
||
_logger?.LogDebug(
|
||
"MQTT driver '{DriverId}': reconnect stood down — the session to {Host}:{Port} was restored elsewhere.",
|
||
_driverId,
|
||
_options.Host,
|
||
_options.Port);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Fires <see cref="Reconnected"/> — ALWAYS, on every reconnect, including persistent
|
||
/// sessions where it is merely redundant, because subscriptions do not survive a clean
|
||
/// session and a reconnect that skips this leaves a healthy-looking, permanently deaf client.
|
||
/// </summary>
|
||
/// <returns>
|
||
/// <c>false</c> if the re-subscribe failed or overran its deadline, having torn the session
|
||
/// down so the next attempt starts from a clean CONNECT — serving a connection that receives
|
||
/// nothing is the one outcome this type must never produce.
|
||
/// </returns>
|
||
private async Task<bool> TryReSubscribeAsync(CancellationToken cancellationToken)
|
||
{
|
||
// Bounded like every other wait here. A subscriber that flows the token cancels promptly; one
|
||
// that ignores it is abandoned at the ceiling rather than being allowed to park the
|
||
// supervisor past DisposeAsync.
|
||
var fanOut = FireReconnectedAsync(cancellationToken);
|
||
|
||
// The ceiling abandons the task rather than stopping it, so its eventual failure must still
|
||
// be observed or it resurfaces as an unobserved TaskException on the finalizer thread.
|
||
_ = fanOut.ContinueWith(
|
||
static abandoned => _ = abandoned.Exception,
|
||
CancellationToken.None,
|
||
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
|
||
TaskScheduler.Default);
|
||
|
||
try
|
||
{
|
||
await fanOut
|
||
.WaitAsync(TimeSpan.FromSeconds(_options.ConnectTimeoutSeconds), cancellationToken)
|
||
.ConfigureAwait(false);
|
||
return true;
|
||
}
|
||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||
{
|
||
throw; // Teardown — let the supervisor's own cancellation handling end the loop.
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger?.LogError(
|
||
ex,
|
||
"MQTT driver '{DriverId}': re-subscribe after reconnect failed or overran {TimeoutSeconds}s; tearing "
|
||
+ "the session down and retrying rather than serving a connection that receives nothing.",
|
||
_driverId,
|
||
_options.ConnectTimeoutSeconds);
|
||
SetState(MqttConnectionState.Reconnecting);
|
||
await ForceDisconnectAsync().ConfigureAwait(false);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Drops a session that is connected but unusable, so the next supervisor pass starts from a
|
||
/// clean CONNECT. Best-effort and bounded — failure here just means the next attempt sees a
|
||
/// client that is already down.
|
||
/// </summary>
|
||
private async Task ForceDisconnectAsync()
|
||
{
|
||
var client = _client;
|
||
if (client is null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
try
|
||
{
|
||
using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(_options.ConnectTimeoutSeconds));
|
||
await client.DisconnectAsync(new MqttClientDisconnectOptions(), deadline.Token).ConfigureAwait(false);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger?.LogDebug(ex, "MQTT driver '{DriverId}': forced disconnect failed.", _driverId);
|
||
}
|
||
}
|
||
|
||
private async Task CloseAsync(IMqttClient client)
|
||
{
|
||
try
|
||
{
|
||
if (client.IsConnected)
|
||
{
|
||
// Bounded even on teardown — a wedged broker must not stall driver shutdown.
|
||
using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(_options.ConnectTimeoutSeconds));
|
||
await client.DisconnectAsync(new MqttClientDisconnectOptions(), deadline.Token).ConfigureAwait(false);
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger?.LogDebug(ex, "MQTT driver '{DriverId}': disconnect during dispose failed; disposing anyway.", _driverId);
|
||
}
|
||
finally
|
||
{
|
||
client.Dispose();
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// <see cref="MqttConnectionState.Disposed"/> is terminal — nothing may move off it. A
|
||
/// check-then-write would let a thread preempted between the two steps resurrect a disposed
|
||
/// connection's state, so the transition is a compare-and-swap: the terminal check and the
|
||
/// write are the same atomic operation.
|
||
/// </summary>
|
||
private void SetState(MqttConnectionState state)
|
||
{
|
||
var desired = (int)state;
|
||
while (true)
|
||
{
|
||
var current = Volatile.Read(ref _state);
|
||
if (current == (int)MqttConnectionState.Disposed || current == desired)
|
||
{
|
||
return;
|
||
}
|
||
|
||
if (Interlocked.CompareExchange(ref _state, desired, current) == current)
|
||
{
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Assembles the MQTTnet client options from <paramref name="options"/>. Pure — it performs
|
||
/// no I/O and touches no network, so it is safe to call on a config-validation path. A
|
||
/// configured <see cref="MqttDriverOptions.CaCertificatePath"/> is read lazily by the
|
||
/// returned certificate-validation callback at handshake time, not here.
|
||
/// </summary>
|
||
/// <param name="options">Broker connection settings.</param>
|
||
/// <param name="clientIdSuffix">
|
||
/// Appended to <see cref="MqttDriverOptions.ClientId"/>, so a browse/probe session can share
|
||
/// the configured identity without colliding with the driver's own client id. When both are
|
||
/// empty MQTTnet generates a random client id.
|
||
/// </param>
|
||
/// <param name="logger">Optional logger for the deferred CA-pin load; never receives credentials.</param>
|
||
public static MqttClientOptions BuildClientOptions(
|
||
MqttDriverOptions options,
|
||
string? clientIdSuffix,
|
||
ILogger? logger = null)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(options);
|
||
|
||
var builder = new MqttClientOptionsBuilder()
|
||
.WithTcpServer(options.Host, options.Port)
|
||
.WithProtocolVersion(MapProtocolVersion(options.ProtocolVersion))
|
||
.WithCleanSession(options.CleanSession)
|
||
.WithKeepAlivePeriod(TimeSpan.FromSeconds(options.KeepAliveSeconds))
|
||
.WithTimeout(TimeSpan.FromSeconds(options.ConnectTimeoutSeconds));
|
||
|
||
var clientId = string.Concat(options.ClientId ?? string.Empty, clientIdSuffix ?? string.Empty);
|
||
if (!string.IsNullOrWhiteSpace(clientId))
|
||
{
|
||
builder = builder.WithClientId(clientId);
|
||
}
|
||
|
||
// An empty username means "connect anonymously"; MQTTnet would otherwise send an empty
|
||
// CONNECT username, which some brokers treat as a failed auth rather than as anonymous.
|
||
if (!string.IsNullOrEmpty(options.Username))
|
||
{
|
||
builder = builder.WithCredentials(options.Username, options.Password ?? string.Empty);
|
||
}
|
||
|
||
builder = builder.WithTlsOptions(tls => ConfigureTls(tls, options, logger));
|
||
|
||
return builder.Build();
|
||
}
|
||
|
||
private static void ConfigureTls(MqttClientTlsOptionsBuilder tls, MqttDriverOptions options, ILogger? logger)
|
||
{
|
||
if (!options.UseTls)
|
||
{
|
||
tls.UseTls(false);
|
||
return;
|
||
}
|
||
|
||
tls.UseTls(true)
|
||
// Explicit SNI / hostname-verification target. Left unset, name validation depends on
|
||
// how MQTTnet infers the host from the endpoint — pin it to the configured host.
|
||
.WithTargetHost(options.Host);
|
||
|
||
if (options.AllowUntrustedServerCertificate)
|
||
{
|
||
// Dev / on-prem escape hatch, off unless explicitly enabled — mirrors the
|
||
// ServerHistorian TLS knobs. This is the ONLY branch that accepts a bad certificate.
|
||
tls.WithAllowUntrustedCertificates(true)
|
||
.WithIgnoreCertificateChainErrors(true)
|
||
.WithCertificateValidationHandler(static _ => true);
|
||
return;
|
||
}
|
||
|
||
if (string.IsNullOrWhiteSpace(options.CaCertificatePath))
|
||
{
|
||
// No pin: MQTTnet's default handler validates against the OS trust store.
|
||
return;
|
||
}
|
||
|
||
tls.WithCertificateValidationHandler(CreatePinnedCaValidator(options.CaCertificatePath, logger));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Builds a certificate-validation callback that pins the broker chain to the PEM CA at
|
||
/// <paramref name="caCertificatePath"/>. The file is loaded lazily on first validation
|
||
/// (i.e. during the TLS handshake) so that this — and therefore
|
||
/// <see cref="BuildClientOptions"/> — stays free of I/O. Any failure to load or to chain
|
||
/// up to the pinned CA rejects the certificate: fail closed.
|
||
/// </summary>
|
||
private static Func<MqttClientCertificateValidationEventArgs, bool> CreatePinnedCaValidator(
|
||
string caCertificatePath,
|
||
ILogger? logger)
|
||
{
|
||
var trustedRoots = new Lazy<X509Certificate2Collection?>(
|
||
() => LoadPemCa(caCertificatePath, logger),
|
||
LazyThreadSafetyMode.ExecutionAndPublication);
|
||
|
||
return args => ValidateAgainstPinnedCa(args, trustedRoots.Value, caCertificatePath, logger);
|
||
}
|
||
|
||
private static X509Certificate2Collection? LoadPemCa(string caCertificatePath, ILogger? logger)
|
||
{
|
||
try
|
||
{
|
||
var collection = new X509Certificate2Collection();
|
||
collection.ImportFromPemFile(caCertificatePath);
|
||
if (collection.Count != 0)
|
||
{
|
||
return collection;
|
||
}
|
||
|
||
logger?.LogError("MQTT CA pin '{CaCertificatePath}' contains no certificates; broker TLS will be rejected.", caCertificatePath);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
logger?.LogError(ex, "MQTT CA pin '{CaCertificatePath}' could not be loaded; broker TLS will be rejected.", caCertificatePath);
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
private static bool ValidateAgainstPinnedCa(
|
||
MqttClientCertificateValidationEventArgs args,
|
||
X509Certificate2Collection? trustedRoots,
|
||
string caCertificatePath,
|
||
ILogger? logger)
|
||
{
|
||
if (trustedRoots is null || trustedRoots.Count == 0)
|
||
{
|
||
// Unreadable pin ⇒ no trust anchor ⇒ refuse. Never silently degrade to the OS store.
|
||
return false;
|
||
}
|
||
|
||
// The pin replaces the trust anchor only. Anything else the platform flagged — a hostname
|
||
// mismatch, an absent certificate — remains fatal.
|
||
if ((args.SslPolicyErrors & ~SslPolicyErrors.RemoteCertificateChainErrors) != SslPolicyErrors.None)
|
||
{
|
||
logger?.LogError("MQTT broker certificate rejected: {SslPolicyErrors}.", args.SslPolicyErrors);
|
||
return false;
|
||
}
|
||
|
||
if (args.Certificate is null)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
var presented = args.Certificate as X509Certificate2;
|
||
X509Certificate2? owned = null;
|
||
if (presented is null)
|
||
{
|
||
owned = X509CertificateLoader.LoadCertificate(args.Certificate.Export(X509ContentType.Cert));
|
||
presented = owned;
|
||
}
|
||
|
||
try
|
||
{
|
||
using var chain = new X509Chain();
|
||
chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
|
||
chain.ChainPolicy.CustomTrustStore.AddRange(trustedRoots);
|
||
|
||
// Revocation is deliberately not checked: the pin exists precisely for self-issued
|
||
// dev / on-prem CAs, which typically publish no CRL or OCSP responder, so checking
|
||
// would fail every such chain. The pin itself is the revocation story — remove the CA.
|
||
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
|
||
|
||
// Seed the intermediates the broker presented during the handshake. A leaf signed by
|
||
// an intermediate that chains up to the pinned root is a common broker topology, and
|
||
// that intermediate arrives on the wire rather than being installed locally — without
|
||
// it in the candidate pool a legitimate trust path simply cannot be built.
|
||
// CustomRootTrust still means only `trustedRoots` may terminate the chain, so a rogue
|
||
// self-signed cert smuggled in here cannot become an anchor.
|
||
// Both sources are read: platforms differ in whether the peer-supplied intermediates
|
||
// land in the incoming chain's elements, its ExtraStore, or both.
|
||
if (args.Chain is not null)
|
||
{
|
||
foreach (var element in args.Chain.ChainElements)
|
||
{
|
||
chain.ChainPolicy.ExtraStore.Add(element.Certificate);
|
||
}
|
||
|
||
chain.ChainPolicy.ExtraStore.AddRange(args.Chain.ChainPolicy.ExtraStore);
|
||
}
|
||
|
||
if (chain.Build(presented))
|
||
{
|
||
return true;
|
||
}
|
||
|
||
logger?.LogError(
|
||
"MQTT broker certificate does not chain to the pinned CA '{CaCertificatePath}': {ChainStatus}.",
|
||
caCertificatePath,
|
||
string.Join(", ", chain.ChainStatus.Select(s => s.Status)));
|
||
return false;
|
||
}
|
||
catch (CryptographicException ex)
|
||
{
|
||
// X509Chain.Build throws on a malformed certificate or an unusable policy. An exception
|
||
// escaping a TLS validation callback surfaces as an opaque handshake crash, so classify
|
||
// it here and refuse: an unverifiable certificate is a rejected certificate.
|
||
logger?.LogError(
|
||
ex,
|
||
"MQTT broker certificate could not be validated against the pinned CA '{CaCertificatePath}'; rejecting.",
|
||
caCertificatePath);
|
||
return false;
|
||
}
|
||
finally
|
||
{
|
||
owned?.Dispose();
|
||
}
|
||
}
|
||
|
||
private static MQTTnet.Formatter.MqttProtocolVersion MapProtocolVersion(MqttProtocolVersion version)
|
||
=> version switch
|
||
{
|
||
MqttProtocolVersion.V311 => MQTTnet.Formatter.MqttProtocolVersion.V311,
|
||
MqttProtocolVersion.V500 => MQTTnet.Formatter.MqttProtocolVersion.V500,
|
||
_ => throw new ArgumentOutOfRangeException(nameof(version), version, "Unsupported MQTT protocol version."),
|
||
};
|
||
}
|