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:
@@ -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
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user