fix(mqtt): stale observed refs BadNoCommunication on connection loss so the identical replay recovers (05/STAB-18 shape a)

This commit is contained in:
Joseph Doherty
2026-08-20 09:46:56 -04:00
parent 7af9baab39
commit ac2f8dc801
6 changed files with 256 additions and 1 deletions
@@ -615,6 +615,81 @@ public sealed class MqttConnectionTests
blackhole.AcceptedCount.ShouldBe(acceptedAfterTheAttempt);
}
// ---------------------------------------------------------------------------------
// ConnectionLost — the quality edge (archreview 05/STAB-18 shape a)
// ---------------------------------------------------------------------------------
/// <summary>
/// <see cref="MqttConnection.ConnectionLost"/> fires exactly once per
/// <c>Connected → Reconnecting</c> edge. It is the signal the ingest paths stale their cached
/// values on, so an extra fire is downstream churn and a missing one leaves every tag
/// reporting Good over a dead session.
/// </summary>
[Fact]
public async Task BrokerDropsTheConnection_FiresConnectionLostOncePerEdge()
{
using var broker = new MiniBroker();
await using var conn = new MqttConnection(BrokerOpts(broker.Port), driverId: "t", logger: null);
var losses = 0;
conn.ConnectionLost += () => Interlocked.Increment(ref losses);
await conn.ConnectAsync(CancellationToken.None);
Volatile.Read(ref losses).ShouldBe(0, "a successful connect is not a loss");
broker.DropAll();
(await WaitUntilAsync(() => Volatile.Read(ref losses) >= 1, TimeSpan.FromSeconds(30))).ShouldBeTrue();
(await WaitUntilAsync(() => conn.State == MqttConnectionState.Connected, TimeSpan.FromSeconds(30)))
.ShouldBeTrue();
Volatile.Read(ref losses).ShouldBe(1, "the reconnect itself is not a second loss");
broker.DropAll();
(await WaitUntilAsync(() => Volatile.Read(ref losses) >= 2, TimeSpan.FromSeconds(30)))
.ShouldBeTrue("a second outage is a second edge");
}
/// <summary>
/// An initial connect that never succeeds is not a <i>loss</i> — there was no session to lose,
/// and staling on it would flip a never-observed tag Bad on a shape the cache already reports
/// as <c>BadWaitingForInitialData</c>.
/// </summary>
[Fact]
public async Task ConnectionLost_DoesNotFireOnAnInitialConnectFailure()
{
using var blackhole = new BlackholeBroker();
var opts = new MqttDriverOptions
{
Host = "127.0.0.1", Port = blackhole.Port, UseTls = false, ConnectTimeoutSeconds = 1,
};
await using var conn = new MqttConnection(opts, driverId: "t", logger: null);
var losses = 0;
conn.ConnectionLost += () => Interlocked.Increment(ref losses);
await Should.ThrowAsync<TimeoutException>(async () => await conn.ConnectAsync(CancellationToken.None));
await Task.Delay(TimeSpan.FromMilliseconds(500), TestContext.Current.CancellationToken);
Volatile.Read(ref losses).ShouldBe(0);
}
/// <summary>
/// Dispose tears the client down, which the library reports as a disconnect. That is an
/// intentional teardown, not an outage: firing here would stale every tag on an ordinary
/// redeploy or shutdown.
/// </summary>
[Fact]
public async Task ConnectionLost_DoesNotFireOnDispose()
{
using var broker = new MiniBroker();
var conn = new MqttConnection(BrokerOpts(broker.Port), driverId: "t", logger: null);
var losses = 0;
conn.ConnectionLost += () => Interlocked.Increment(ref losses);
await conn.ConnectAsync(CancellationToken.None);
await conn.DisposeAsync();
await Task.Delay(TimeSpan.FromMilliseconds(500), TestContext.Current.CancellationToken);
Volatile.Read(ref losses).ShouldBe(0);
}
// ---------------------------------------------------------------------------------
// Task 4 — the reconnect supervisor, against a real (minimal) broker
// ---------------------------------------------------------------------------------
@@ -136,4 +136,94 @@ public sealed class MqttSubscriptionManagerRecoveryTests
raised[0].Value.ShouldBe(21.5);
raised[0].Handle.ShouldBe(second);
}
// ---------------------------------------------------------------------------------
// (B) Connection-loss stale — 05/STAB-18 shape (a)
// ---------------------------------------------------------------------------------
/// <summary>
/// A lost broker session makes every observed reference <c>BadNoCommunication</c> — with its
/// last value retained, the Part 8 last-known-value convention a SCADA client expects. MQTT
/// previously marked nothing at all on its own disconnect edge, so the tags stayed Good until
/// the host's 90 s comms-loss watchdog noticed (if it noticed at all — MQTTnet usually
/// reconnects well inside that window).
/// </summary>
[Fact]
public async Task OnConnectionLost_stales_every_observed_ref_keeping_its_value()
{
var mgr = NewManager();
mgr.HandleMessage(TempTopic, """{"value":21.5}"""u8.ToArray(), retained: false);
await mgr.SubscribeAsync([TempPath, PressPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
var raised = Record(mgr); // recorded AFTER the subscribe, so the initial push is not counted
mgr.OnConnectionLost();
raised.Count.ShouldBe(1, "the never-observed ref has nothing to stale");
raised[0].Reference.ShouldBe(TempPath);
raised[0].StatusCode.ShouldBe(BadNoCommunication);
raised[0].Value.ShouldBe(21.5, "the last known value is retained, not nulled");
mgr.Values.Read(TempPath).StatusCode.ShouldBe(BadNoCommunication);
mgr.Values.Read(TempPath).Value.ShouldBe(21.5);
}
/// <summary>
/// <b>The contract test for this seam.</b> After the stale, the broker's retained replay of the
/// <b>identical</b> payload is raised Good — because the cached status now differs even though
/// the value does not. Replaying a <em>different</em> payload here would pass against the
/// pre-fix build and prove nothing; the identical payload is the whole point.
/// </summary>
[Fact]
public async Task After_the_stale_the_identical_retained_replay_is_raised_Good()
{
var mgr = NewManager();
mgr.HandleMessage(TempTopic, """{"value":21.5}"""u8.ToArray(), retained: false);
await mgr.SubscribeAsync([TempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
mgr.OnConnectionLost();
var raised = Record(mgr);
mgr.HandleMessage(TempTopic, """{"value":21.5}"""u8.ToArray(), retained: true); // the retained seed
raised.Count.ShouldBe(1);
raised[0].Reference.ShouldBe(TempPath);
raised[0].Value.ShouldBe(21.5);
raised[0].StatusCode.ShouldBe(Good);
}
/// <summary>
/// A second loss with no intervening message raises nothing: the ref is already Bad, and a
/// re-stamp would be pure churn on the downstream fan-out.
/// </summary>
[Fact]
public async Task OnConnectionLost_does_not_re_raise_an_already_bad_ref()
{
var mgr = NewManager();
mgr.HandleMessage(TempTopic, """{"value":21.5}"""u8.ToArray(), retained: false);
await mgr.SubscribeAsync([TempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
mgr.OnConnectionLost();
var raised = Record(mgr);
mgr.OnConnectionLost();
raised.ShouldBeEmpty();
}
/// <summary>
/// The stale runs on MQTTnet's dispatcher thread, so it must complete synchronously and never
/// await: parking that thread stalls delivery for every other subscription on the connection.
/// </summary>
[Fact]
public async Task OnConnectionLost_completes_on_the_calling_thread()
{
var mgr = NewManager();
mgr.HandleMessage(TempTopic, """{"value":21.5}"""u8.ToArray(), retained: false);
await mgr.SubscribeAsync([TempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
var callingThread = Environment.CurrentManagedThreadId;
var raisedOn = 0;
mgr.OnDataChange += (_, _) => raisedOn = Environment.CurrentManagedThreadId;
mgr.OnConnectionLost();
raisedOn.ShouldBe(callingThread);
}
}