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
@@ -583,6 +583,51 @@ public sealed class MqttSubscriptionManagerTests
mgr.Values.Read(OvenTempPath).StatusCode.ShouldBe(Good);
}
/// <summary>
/// <b>Driver.Mqtt-007.</b> Live subscribed topics that resolve to ZERO filters re-establish
/// nothing, which is the same outcome as a total SUBACK failure and must get the same answer:
/// throw, so <see cref="MqttConnection"/> tears the fresh session down and retries rather than
/// serving a connected-but-deaf driver. A warning was not enough — the whole point of the type's
/// split contract is that "no subscription was re-established" is fatal to the session.
/// </summary>
/// <remarks>
/// The state is reached the only way production can reach it: <see cref="MqttSubscriptionManager.Register"/>
/// prunes <c>_subscribedTopics</c> of the topics its new table no longer carries, but a SUBSCRIBE
/// already in flight records its topic <i>after</i> that prune has run — so a redeploy landing
/// inside the subscribe round-trip leaves a live topic no authored tag resolves to.
/// </remarks>
[Fact]
public async Task OnReconnectedAsync_LiveTopicResolvingNoFilter_Throws_RatherThanServingADeafSession()
{
var transport = new FakeTransport();
var mgr = NewManager(transport);
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]);
transport.DuringSubscribe = () => mgr.Register([]); // the redeploy lands mid-round-trip
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
transport.DuringSubscribe = null;
await Should.ThrowAsync<InvalidOperationException>(
() => mgr.OnReconnectedAsync(TestContext.Current.CancellationToken));
}
/// <summary>
/// The falsifying control: a reconnect with no live subscriptions at all re-establishes nothing
/// because there is nothing to establish, and must stay a no-op. Throwing here would tear down
/// the session of every driver whose tags have not been subscribed yet.
/// </summary>
[Fact]
public async Task OnReconnectedAsync_NoLiveSubscriptions_IsANoOp()
{
var transport = new FakeTransport();
var mgr = NewManager(transport);
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]);
await Should.NotThrowAsync(() => mgr.OnReconnectedAsync(TestContext.Current.CancellationToken));
transport.Calls.Count.ShouldBe(0);
}
// ---------------------------------------------------------------------------------
// C1 — wildcard-authored tags on the paths that reconstruct state from a FILTER string.
// Delivery matches an incoming topic (concrete); reconnect + degrade key off the subscribed
@@ -898,10 +943,18 @@ public sealed class MqttSubscriptionManagerTests
/// <summary>When set, the whole subscribe call throws instead of answering.</summary>
public Exception? Throw { get; set; }
/// <summary>
/// Runs inside the subscribe round-trip, so a test can land a concurrent
/// <see cref="MqttSubscriptionManager.Register"/> in the window between its prune and this
/// call recording its topic as live.
/// </summary>
public Action? DuringSubscribe { get; set; }
public Task<IReadOnlyList<MqttSubscribeOutcome>> SubscribeAsync(
IReadOnlyList<MqttTopicSubscription> filters, CancellationToken cancellationToken)
{
Calls.Add(filters);
DuringSubscribe?.Invoke();
if (Throw is not null) throw Throw;
IReadOnlyList<MqttSubscribeOutcome> outcomes =
[.. filters.Select(f => new MqttSubscribeOutcome(f.Topic, !Reject(f.Topic), Reject(f.Topic) ? "NotAuthorized" : "GrantedQoS1"))];