fix(mqtt): dispose an unpublished connection and stamp lifecycle generations (R06:M-3, M-10)
A refused initial connect (or a failed Sparkplug establish) now disposes the MqttConnection it built. Every lifecycle operation takes a generation (initialize inside the gate; shutdown/dispose before waiting for it): a wedged initialize that completes after a timed-out ShutdownAsync's ungated teardown releases its session instead of publishing it, and a superseded operation's health/host-state writes are refused. TeardownAsync captures every field before disposing so an ungated teardown never reaches into a later initialize's state. Refs #744 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
@@ -219,6 +219,75 @@ public sealed class MqttDriverSessionLifecycleTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>R06:M-3.</b> A refused initial connect used to rethrow without disposing the
|
||||
/// <see cref="MqttConnection"/> (and its <c>IMqttClient</c>) it had just built. Control: a
|
||||
/// successful initialize leaves its connection live, so the seam is not reporting "disposed" for
|
||||
/// everything.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task InitializeAsync_RefusedConnect_DisposesTheConnectionItBuilt()
|
||||
{
|
||||
using var broker = new MiniBroker { ConnAckReturnCode = MiniBroker.ReturnCodeNotAuthorized };
|
||||
var options = BrokerOptions(broker.Port);
|
||||
await using var driver = new MqttDriver(options, "d", null);
|
||||
var built = new List<MqttConnection>();
|
||||
driver.ConnectionCreatedForTests = c => built.Add(c);
|
||||
|
||||
await Should.ThrowAsync<Exception>(() => driver.InitializeAsync(Json(options), TestContext.Current.CancellationToken));
|
||||
|
||||
built.ShouldHaveSingleItem().State.ShouldBe(MqttConnectionState.Disposed);
|
||||
|
||||
broker.ConnAckReturnCode = MiniBroker.ReturnCodeAccepted;
|
||||
await driver.InitializeAsync(Json(options), TestContext.Current.CancellationToken);
|
||||
built.Count.ShouldBe(2);
|
||||
built[1].State.ShouldBe(MqttConnectionState.Connected);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>R06:M-10 + the lifecycle-generation rule.</b> A shutdown that times out on the lifecycle gate
|
||||
/// tears down ungated; the wedged initialize that held the gate must then NOT publish its freshly
|
||||
/// connected session behind that teardown, and its late health write must not overwrite the
|
||||
/// shutdown's. The positive controls: the wedged init really did reach the broker (AcceptedCount),
|
||||
/// and a later initialize connects and reports Healthy.
|
||||
/// </summary>
|
||||
[Trait("Category", "SlowUnit")]
|
||||
[Fact]
|
||||
public async Task AWedgedInit_CompletingAfterATimedOutShutdown_PublishesNothing()
|
||||
{
|
||||
using var broker = new MiniBroker();
|
||||
var options = BrokerOptions(broker.Port) with { ConnectTimeoutSeconds = 1 };
|
||||
await using var driver = new MqttDriver(options, "d", null);
|
||||
|
||||
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
driver.BeforeConnectHookForTests = async _ =>
|
||||
{
|
||||
entered.TrySetResult();
|
||||
await release.Task;
|
||||
};
|
||||
|
||||
var initialize = Task.Run(() => driver.InitializeAsync(Json(options), CancellationToken.None));
|
||||
await entered.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
|
||||
|
||||
// Times out on the gate (1 s) and tears down ungated.
|
||||
await driver.ShutdownAsync(TestContext.Current.CancellationToken);
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Unknown);
|
||||
|
||||
driver.BeforeConnectHookForTests = null;
|
||||
release.SetResult();
|
||||
await Should.ThrowAsync<OperationCanceledException>(() => initialize);
|
||||
|
||||
broker.AcceptedCount.ShouldBe(1, "the wedged initialize did connect — the control for the absence below");
|
||||
await WaitForAsync(() => broker.LiveConnections == 0, "the superseded session to be torn down");
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Unknown, "a superseded init must not overwrite newer health");
|
||||
driver.GetHostStatuses().ShouldHaveSingleItem().State.ShouldBe(HostState.Unknown);
|
||||
|
||||
await driver.InitializeAsync(Json(options), TestContext.Current.CancellationToken);
|
||||
await WaitForAsync(() => broker.LiveConnections == 1, "the fresh session");
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
private static async Task WaitForAsync(Func<bool> condition, string what)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10);
|
||||
|
||||
Reference in New Issue
Block a user