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:
@@ -0,0 +1,133 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The driver's own session lifecycle, against the <see cref="MiniBroker"/> fake: what
|
||||
/// <see cref="MqttDriver.InitializeAsync"/> does to the session it is <i>replacing</i>, and what a
|
||||
/// mis-authored duration knob does to the two paths whose whole job is to not fail
|
||||
/// (<see cref="MqttDriver.ShutdownAsync"/> / <see cref="MqttDriver.DisposeAsync"/>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Plain mode deliberately: it is the only mode whose initialize needs no SUBSCRIBE, and
|
||||
/// <see cref="MiniBroker"/> answers CONNECT and PINGREQ and nothing else — so a Sparkplug driver
|
||||
/// would park on its group SUBSCRIBE until the deadline and prove nothing about connections.
|
||||
/// </remarks>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class MqttDriverSessionLifecycleTests
|
||||
{
|
||||
/// <summary>
|
||||
/// <b>Driver.Mqtt-002.</b> <c>InitializeCoreAsync</c> publishes <c>_connection</c> — an
|
||||
/// <see cref="IAsyncDisposable"/> — so re-initializing a live driver without tearing down first
|
||||
/// stranded a connected, supervised broker session that no later dispose could reach, and the
|
||||
/// abandoned session's reconnect supervisor kept it alive against the broker indefinitely.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Reachable with no teardown of the driver's own: <c>DriverInstanceActor</c> re-runs
|
||||
/// <c>InitializeAsync</c> with no <c>ShutdownAsync</c> when an <c>ApplyDelta</c> arrives while
|
||||
/// the driver is Connecting/Reconnecting (<c>AdoptConfigDuringInit</c>). The
|
||||
/// <c>teardownFirst</c> flag on the #527 retry path covers only the retry path.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The assertion is the broker's <b>live socket count</b>, not the driver's own view: a leak
|
||||
/// is precisely the case where the driver believes it holds one session and the broker is
|
||||
/// serving two. <see cref="MiniBroker.AcceptedCount"/> is asserted alongside it as the
|
||||
/// control — without it, a second initialize that never dialled at all would satisfy
|
||||
/// "exactly one live connection" just as well.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task InitializeAsync_OnALiveDriver_TearsDownThePreviousSession()
|
||||
{
|
||||
using var broker = new MiniBroker();
|
||||
var options = BrokerOptions(broker.Port);
|
||||
await using var driver = new MqttDriver(options, "d", null);
|
||||
|
||||
await driver.InitializeAsync(Json(options), TestContext.Current.CancellationToken);
|
||||
await WaitForAsync(() => broker.LiveConnections == 1, "the first session to be established");
|
||||
|
||||
await driver.InitializeAsync(Json(options), TestContext.Current.CancellationToken);
|
||||
|
||||
broker.AcceptedCount.ShouldBe(2, "the second initialize must genuinely open a new session");
|
||||
await WaitForAsync(
|
||||
() => broker.LiveConnections == 1,
|
||||
"the replaced session to be torn down rather than left live and unreachable");
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>Driver.Mqtt-003.</b> <c>connectTimeoutSeconds</c> carries a <c>[Range]</c> annotation that
|
||||
/// nothing validates on the runtime path — the blob is deserialized straight out of the deploy
|
||||
/// artifact. A negative value is outside the legal range of both
|
||||
/// <see cref="SemaphoreSlim.WaitAsync(TimeSpan)"/> and <see cref="CancellationTokenSource"/>, so
|
||||
/// it threw <see cref="ArgumentOutOfRangeException"/> out of shutdown and dispose — the two calls
|
||||
/// that must always complete, and the ones a host makes while tearing a bad deployment down.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ShutdownAndDispose_WithANegativeConnectTimeout_DoNotThrow()
|
||||
{
|
||||
var options = BrokerOptions(1) with { ConnectTimeoutSeconds = -1, KeepAliveSeconds = -1 };
|
||||
var driver = new MqttDriver(options, "d", null);
|
||||
|
||||
await Should.NotThrowAsync(async () =>
|
||||
await driver.ShutdownAsync(TestContext.Current.CancellationToken));
|
||||
await Should.NotThrowAsync(async () => await driver.DisposeAsync());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The control for the test above: the normalization must not quietly rewrite a <i>legal</i>
|
||||
/// value, or a deployment's authored connect budget would silently become the default.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task InitializeAsync_WithAnAuthoredConnectTimeout_KeepsIt()
|
||||
{
|
||||
using var broker = new MiniBroker { Blackhole = true };
|
||||
var options = BrokerOptions(broker.Port) with { ConnectTimeoutSeconds = 2 };
|
||||
await using var driver = new MqttDriver(options, "d", null);
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
await Should.ThrowAsync<TimeoutException>(async () =>
|
||||
await driver.InitializeAsync(Json(options), TestContext.Current.CancellationToken));
|
||||
sw.Stop();
|
||||
|
||||
sw.Elapsed.ShouldBeLessThan(
|
||||
TimeSpan.FromSeconds(10),
|
||||
"the authored 2s budget must still bound the connect, not the 15s default");
|
||||
}
|
||||
|
||||
private static async Task WaitForAsync(Func<bool> condition, string what)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (condition())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await Task.Delay(25, TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
throw new Xunit.Sdk.XunitException($"Timed out waiting for {what}.");
|
||||
}
|
||||
|
||||
private static MqttDriverOptions BrokerOptions(int port) => new()
|
||||
{
|
||||
Mode = MqttMode.Plain,
|
||||
Host = "127.0.0.1",
|
||||
Port = port,
|
||||
UseTls = false,
|
||||
ProtocolVersion = MqttProtocolVersion.V311,
|
||||
KeepAliveSeconds = 300,
|
||||
ConnectTimeoutSeconds = 5,
|
||||
ReconnectMinBackoffSeconds = 1,
|
||||
ReconnectMaxBackoffSeconds = 2,
|
||||
};
|
||||
|
||||
private static string Json(MqttDriverOptions options) => JsonSerializer.Serialize(options, MqttJson.Options);
|
||||
}
|
||||
@@ -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