fix(mqtt): make connect idempotent, bound the reconnect callback, stop State lying

Closes the connect-vs-connect defect: MQTTnet throws (and raises no DisconnectedAsync)
on a connect-while-connected, so a caller's ConnectAsync and the reconnect supervisor
corrupted each other in both directions. Both paths now check first; when the supervisor
finds the session already restored it stands down WITHOUT firing Reconnected.

Reconnected becomes Func<CancellationToken, Task>, fed from the lifetime token and capped
at ConnectTimeoutSeconds, so a hung re-subscribe can no longer park the supervisor.
Connected is published only after the re-subscribe succeeds; a supervisor that dies now
reports Faulted instead of Reconnecting forever.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
Joseph Doherty
2026-07-24 15:32:41 -04:00
parent 768fd87774
commit a6370a26f8
2 changed files with 545 additions and 56 deletions
@@ -22,8 +22,10 @@ public enum MqttConnectionState
Reconnecting = 2,
/// <summary>
/// Unrecoverable <i>configuration</i>: retrying cannot help, so the supervisor stops. Only
/// failures raised while assembling the client options (before any I/O) land here.
/// 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,
@@ -69,7 +71,64 @@ public enum MqttConnectionState
/// 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.
/// 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
@@ -99,7 +158,15 @@ public sealed class MqttConnection : IAsyncDisposable
/// </summary>
private readonly SemaphoreSlim _lifecycleGate = new(1, 1);
/// <summary>Cancelled by <see cref="DisposeAsync"/>; aborts the supervisor and any in-flight connect.</summary>
/// <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;
@@ -135,12 +202,20 @@ public sealed class MqttConnection : IAsyncDisposable
/// <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).
/// <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>
public event Func<Task>? Reconnected;
/// <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>Whether the underlying client currently holds an established MQTT session.</summary>
public bool IsConnected => _client?.IsConnected ?? false;
@@ -285,11 +360,20 @@ public sealed class MqttConnection : IAsyncDisposable
/// supervisor takes over keeping the session alive.
/// </summary>
/// <remarks>
/// 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>
/// 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>
@@ -297,21 +381,22 @@ public sealed class MqttConnection : IAsyncDisposable
/// <exception cref="ObjectDisposedException">
/// The connection was disposed, either before the attempt started or while it was in flight.
/// </exception>
public Task ConnectAsync(CancellationToken cancellationToken)
public async Task ConnectAsync(CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(Disposed, this);
return ConnectCoreAsync(startSupervisor: true, cancellationToken);
await ConnectCoreAsync(supervisorAttempt: false, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Invokes every <see cref="Reconnected"/> subscriber. Walks the invocation list explicitly:
/// awaiting a multicast <see cref="Func{Task}"/> 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.
/// 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>
internal async Task FireReconnectedAsync()
/// <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)
@@ -320,11 +405,11 @@ public sealed class MqttConnection : IAsyncDisposable
}
List<Exception>? failures = null;
foreach (var subscriber in subscribers.GetInvocationList().Cast<Func<Task>>())
foreach (var subscriber in subscribers.GetInvocationList().Cast<Func<CancellationToken, Task>>())
{
try
{
await subscriber().ConfigureAwait(false);
await subscriber(cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
@@ -340,7 +425,23 @@ public sealed class MqttConnection : IAsyncDisposable
}
}
private async Task ConnectCoreAsync(bool startSupervisor, CancellationToken cancellationToken)
/// <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
@@ -397,6 +498,23 @@ public sealed class MqttConnection : IAsyncDisposable
_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;
}
await client.ConnectAsync(clientOptions, linked.Token).ConfigureAwait(false);
if (AfterConnectHookForTests is { } hook)
@@ -417,12 +535,15 @@ public sealed class MqttConnection : IAsyncDisposable
+ $"{_options.Host}:{_options.Port} was in flight; the established session was torn down.");
}
SetState(MqttConnectionState.Connected);
if (startSupervisor)
// 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)
{
_reconnectWorker ??= Task.Run(() => ReconnectSupervisorAsync(_lifetimeCts.Token));
SetState(MqttConnectionState.Connected);
}
StartSupervisorIfCallerConnect(supervisorAttempt);
return true;
}
catch (ObjectDisposedException)
{
@@ -438,6 +559,18 @@ public sealed class MqttConnection : IAsyncDisposable
}
}
/// <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 connect failure onto this type's documented contract. MQTTnet wraps a cancelled
/// connect in <c>MqttConnectingFailedException</c> rather than letting the
@@ -537,6 +670,14 @@ public sealed class MqttConnection : IAsyncDisposable
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(
@@ -546,11 +687,13 @@ public sealed class MqttConnection : IAsyncDisposable
cancellationToken)
.ConfigureAwait(false);
bool established;
try
{
await ConnectCoreAsync(startSupervisor: false, cancellationToken).ConfigureAwait(false);
established = await ConnectCoreAsync(supervisorAttempt: true, cancellationToken)
.ConfigureAwait(false);
}
catch (ObjectDisposedException)
catch (ObjectDisposedException) when (Disposed)
{
return;
}
@@ -575,25 +718,24 @@ public sealed class MqttConnection : IAsyncDisposable
continue;
}
try
if (!established)
{
// ALWAYS, on every reconnect — including persistent sessions, where this is
// merely redundant. Subscriptions do not survive a clean session, and a
// reconnect that skips this leaves a healthy-looking, permanently deaf client.
await FireReconnectedAsync().ConfigureAwait(false);
// 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;
}
catch (Exception ex)
if (!await TryReSubscribeAsync(cancellationToken).ConfigureAwait(false))
{
_logger?.LogError(
ex,
"MQTT driver '{DriverId}': re-subscribe after reconnect failed; tearing the session down "
+ "and retrying rather than serving a connection that receives nothing.",
_driverId);
SetState(MqttConnectionState.Reconnecting);
await ForceDisconnectAsync().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.",
@@ -612,7 +754,9 @@ public sealed class MqttConnection : IAsyncDisposable
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.
// 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 "
@@ -621,6 +765,71 @@ public sealed class MqttConnection : IAsyncDisposable
}
}
/// <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
@@ -666,15 +875,28 @@ public sealed class MqttConnection : IAsyncDisposable
}
}
/// <summary><see cref="MqttConnectionState.Disposed"/> is terminal — nothing may move off it.</summary>
/// <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)
{
if (Volatile.Read(ref _state) == (int)MqttConnectionState.Disposed)
var desired = (int)state;
while (true)
{
return;
}
var current = Volatile.Read(ref _state);
if (current == (int)MqttConnectionState.Disposed || current == desired)
{
return;
}
Volatile.Write(ref _state, (int)state);
if (Interlocked.CompareExchange(ref _state, desired, current) == current)
{
return;
}
}
}
/// <summary>