merge: MQTT driver+transport review findings resolved (Driver.Mqtt-006/-007, Driver.Mqtt.Transport-002)

This commit is contained in:
Joseph Doherty
2026-08-10 08:01:34 -04:00
8 changed files with 270 additions and 24 deletions
@@ -98,6 +98,18 @@ public static class MqttClientOptionsFactory
{
builder = builder.WithCredentials(options.Username, options.Password ?? string.Empty);
}
else if (!string.IsNullOrEmpty(options.Password))
{
// A password with no username is dropped, and the drop is the direction an operator cannot
// see: the config reads as authenticated and the CONNECT goes out anonymous. MQTT 3.1.1 may
// not carry a password without a username, so synthesizing one is not available on every
// protocol version this factory serves — and inventing a password-only CONNECT under 5.0
// only would change the wire behaviour of deployments that connect happily today. Say so
// once, here, and leave the configured values authoritative. Never format the secret.
logger?.LogWarning(
"MQTT: a password is configured with no username, so it is NOT being sent — this connection "
+ "will be made anonymously. Set the username, or clear the password to make that intentional.");
}
builder = builder.WithTlsOptions(tls => ConfigureTls(tls, options, logger));
@@ -397,6 +397,15 @@ public sealed class MqttSubscriptionManager
/// rest keep flowing. That is the plan's "SUBACK failure → per-ref Bad, not hang", scoped
/// to the case where it does not cost the whole driver.
/// </para>
/// <para>
/// <b>Live topics resolving to zero filters is a total failure too</b> — it re-establishes
/// nothing, so it throws by the same rule rather than warning and returning. It costs no
/// other tag (every live topic is in the set that resolved to nothing) and it is not
/// permanent, because the retry runs against whatever <see cref="Register"/> has published
/// by then. It is reachable only when a deploy lands inside a subscribe round-trip:
/// <see cref="Register"/> prunes <c>_subscribedTopics</c> of the topics its new table no
/// longer carries, but an in-flight SUBSCRIBE records its own topic after that prune ran.
/// </para>
/// </remarks>
/// <param name="cancellationToken">The connection's lifetime token; cancelled by its dispose.</param>
/// <returns>A task that completes when the re-subscribe has been answered.</returns>
@@ -421,24 +430,21 @@ public sealed class MqttSubscriptionManager
var filters = BuildFilters(live, _defaultQos);
if (filters.Count == 0)
{
// Silence here is how the wildcard defect hid: no exception, no log, no SUBSCRIBE. If any
// topic was live, resolving zero filters for it is an anomaly and must be loud.
// Silence here is how the wildcard defect hid: no exception, no log, no SUBSCRIBE. Live
// topics resolving to zero filters re-establishes nothing, which is the total-failure case
// by a different route, so it gets the total-failure answer — see the remarks.
if (subscribed.Count > 0)
{
_logger?.LogWarning(
"MQTT driver '{DriverId}': reconnect resolved NO filters for {TopicCount} live topic(s) "
+ "({Topics}); those subscriptions are not being re-established.",
_driverId,
subscribed.Count,
string.Join(", ", subscribed));
}
else
{
_logger?.LogDebug(
"MQTT driver '{DriverId}': reconnect had no live subscriptions to re-establish.",
_driverId);
throw new InvalidOperationException(
$"MQTT driver '{_driverId}': reconnect resolved NO filters for {subscribed.Count} live "
+ $"topic(s) ({string.Join(", ", subscribed)}); tearing the session down rather than "
+ "serving a connection that receives nothing.");
}
_logger?.LogDebug(
"MQTT driver '{DriverId}': reconnect had no live subscriptions to re-establish.",
_driverId);
return;
}
@@ -73,7 +73,10 @@ public sealed class SparkplugPrimaryHost : IAsyncDisposable
private readonly ILogger? _logger;
private readonly TimeProvider _timeProvider;
/// <summary>Serializes claim/release against each other and against <see cref="DisposeAsync"/>.</summary>
/// <summary>
/// Serializes claim/release against each other and against <see cref="DisposeAsync"/>. Never
/// disposed — see <see cref="DisposeAsync"/> for why.
/// </summary>
private readonly SemaphoreSlim _gate = new(1, 1);
private CancellationTokenSource? _loopCts;
@@ -275,7 +278,14 @@ public sealed class SparkplugPrimaryHost : IAsyncDisposable
ex, "MQTT driver '{DriverId}': Sparkplug primary-host stand-down failed during dispose.", _driverId);
}
_gate.Dispose();
// _gate is deliberately NOT disposed, matching MqttConnection's rule for its own lifecycle
// primitives. StopAsync cancels the reconcile loop without awaiting it, so a ReconcileAsync can
// still be inside the gate — or on its way to the finally-release — when this returns. Against a
// disposed semaphore that release throws an ObjectDisposedException naming SemaphoreSlim, which
// is logged as a reconcile failure and tells an operator nothing (and hides whatever the
// reconcile was really failing on). Left undisposed the late release is a harmless no-op: the
// gate holds no timer and no registration, and ClaimAsync re-checks _disposed under it, so no
// claim can be created after dispose either way.
}
private async Task ReconcileLoopAsync(CancellationToken ct)