fix(mqtt): a reconnect that re-establishes nothing tears the session down

Driver.Mqtt-007 (Low, error handling). OnReconnectedAsync throws when a
re-subscribe is granted zero filters — MqttConnection's contract is that a
throwing Reconnected subscriber tears the fresh session down and retries under
backoff, because a connected-but-deaf driver is the one outcome this path must
never serve. But when the live subscribed topics resolved to zero filters in the
first place it logged a warning and returned, reaching the same "nothing was
re-established" state by a different route and giving it the opposite answer.

Throw there too, and say so in the remarks so the type states ONE rule. It costs
no other tag — every live topic is in the set that resolved to nothing, so there
is nothing left flowing to protect, which is the whole reason the partial-
rejection case is exempt — and it is not permanent: the retry runs against
whatever Register has published by then.

Test reaches the state the only way production can: Register prunes
_subscribedTopics of the topics its new table no longer carries, but a SUBSCRIBE
already in flight records its own topic AFTER that prune ran, so a deploy landing
inside the round-trip leaves a live topic no authored tag resolves to. Paired
with the control that a reconnect with no live subscriptions at all stays a
no-op — throwing there would tear down the session of every driver whose tags
have not been subscribed yet.
This commit is contained in:
Joseph Doherty
2026-08-10 07:58:29 -04:00
parent 5d4dd17d7f
commit 2298485bda
2 changed files with 73 additions and 14 deletions
@@ -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;
}