fix(mqtt): stale observed refs BadNoCommunication on connection loss so the identical replay recovers (05/STAB-18 shape a)
This commit is contained in:
+90
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user