fix(mqtt): session-rebind subscriptions, re-init teardown, timeout clamp

Findings from the per-module review of Driver.Mqtt at b990adae
(code-reviews/Driver.Mqtt/findings.md records all ten; these are the fixed
ones).

Driver.Mqtt-001 (High) — MqttSubscriptionManager._subscribedTopics is a claim
about the broker session it established filters on, and AttachTo never cleared
it, but the manager OUTLIVES a session rebuild (AdoptOptions keeps it whenever
the ingest identity is unchanged). After a driver-host reconnect
(ShutdownAsync + InitializeAsync, the #527 watchdog's retry) or a
ReinitializeAsync that only changed the endpoint/credentials, the host's
re-subscribe resolved an empty pending set, sent no SUBSCRIBE at all, and left
the driver Connected/Healthy on a session carrying ZERO subscriptions — no
error, no Bad status, values frozen until the new session happened to drop.
AttachTo now forgets them, mirroring SparkplugIngestor.AttachTo's reset.

Driver.Mqtt-002 (High) — InitializeCoreAsync published _connection and
_primaryHost over whatever the driver already held, so a second initialize
stranded a connected, self-supervising broker session (plus a second Sparkplug
primary-host claim) that no later dispose could reach. Reachable without any
teardown of ours via DriverInstanceActor.AdoptConfigDuringInit. Now tears down
first, under the gate both callers already hold.

Driver.Mqtt-003 (Medium) — connectTimeoutSeconds is [Range]-annotated but
unvalidated on the runtime path; 0 makes every deadline fire immediately and a
negative value throws ArgumentOutOfRangeException out of ShutdownAsync and
DisposeAsync. Normalized once, at the seam every options instance passes.

Also: ShutdownAsync keeps the live last-message instant (-004), three usings
left behind by #512 removed (-005), stale remarks corrected (-009, -010), and
docs/drivers/Mqtt.md now distinguishes the two kinds of reconnect.

Tests 708 -> 713. All three regression tests were confirmed to fail against
the un-fixed code, with two controls that pass in either state.
This commit is contained in:
Joseph Doherty
2026-08-10 06:59:18 -04:00
parent b990adae55
commit 2b4b3fcbbf
7 changed files with 333 additions and 26 deletions
@@ -827,6 +827,66 @@ public sealed class MqttSubscriptionManagerTests
transport.Calls.Count.ShouldBe(1); // the initial subscribe only — no zombie re-subscribe
}
// ---------------------------------------------------------------------------------
// Session rebind — this manager OUTLIVES the broker session it established filters on
// ---------------------------------------------------------------------------------
/// <summary>
/// <b>Driver.Mqtt-001.</b> A driver-host reconnect (ShutdownAsync + InitializeAsync, the #527
/// watchdog's path) builds a brand-new <see cref="MqttConnection"/> but keeps <i>this</i> manager
/// — <c>MqttDriver.AdoptOptions</c> only replaces it when the ingest identity changed. The new
/// session holds none of the old one's subscriptions, so the host's post-reconnect re-subscribe
/// must actually reach the broker. While <c>_subscribedTopics</c> survived the rebind it resolved
/// an empty pending set, sent no SUBSCRIBE at all, and left the driver Connected/Healthy and
/// permanently deaf.
/// </summary>
/// <remarks>
/// The rebound connection is deliberately a real, <b>unconnected</b> one: its
/// <c>SubscribeAsync</c> refuses ("no established session"), which <c>EstablishAsync</c> catches
/// and turns into <c>BadCommunicationError</c> on the affected references. That Bad IS the proof
/// the subscribe was attempted — with the bug the tag keeps the Good value it was last delivered,
/// because nothing is attempted at all.
/// </remarks>
[Fact]
public async Task AttachTo_ForgetsTheFiltersEstablishedOnThePreviousSession()
{
var transport = new FakeTransport();
var mgr = NewManager(transport);
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]);
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
mgr.HandleMessage(OvenTopic, "21.5"u8.ToArray(), retained: false);
mgr.Values.Read(OvenTempPath).StatusCode.ShouldBe(Good);
// The session underneath is rebuilt and this manager is re-attached to it.
await using var rebound = new MqttConnection(
new MqttDriverOptions { Host = "127.0.0.1", Port = 1, UseTls = false }, "mqtt-1");
mgr.AttachTo(rebound);
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
mgr.Values.Read(OvenTempPath).StatusCode.ShouldBe(
BadCommunicationError,
"the re-subscribe must be issued against the NEW session, not skipped as already-established");
}
/// <summary>
/// The falsifiability control for the test above: within ONE session the dedupe must still hold,
/// or the fix would simply be "re-SUBSCRIBE everything on every subscribe" — which re-triggers
/// every topic's retained seed and is the behaviour <c>_subscribedTopics</c> exists to prevent.
/// </summary>
[Fact]
public async Task SubscribeAsync_TwiceOnTheSameSession_StillIssuesOneSubscribe()
{
var transport = new FakeTransport();
var mgr = NewManager(transport);
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]);
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
transport.Calls.Count.ShouldBe(1);
}
/// <summary>Records every subscribe call so dedupe / re-subscribe can be asserted without a broker.</summary>
private sealed class FakeTransport : IMqttSubscribeTransport
{