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
@@ -349,6 +349,31 @@ public sealed class MqttConnection : IAsyncDisposable, IMqttSubscribeTransport,
/// </remarks>
public event Func<CancellationToken, Task>? Reconnected;
/// <summary>
/// Raised on the <c>Connected → Reconnecting</c> edge — the session this connection had is
/// gone. Ingest paths stale their cached values to <c>BadNoCommunication</c> here (archreview
/// 05/STAB-18): without it, MQTT reports Good over a dead broker until the host's 90 s
/// comms-loss watchdog notices, and MQTTnet's own supervisor usually reconnects well inside
/// that window so the outage is never surfaced at all.
/// </summary>
/// <remarks>
/// <para>
/// <b>Runs on MQTTnet's dispatcher thread</b> (this is raised from the library's
/// <c>DisconnectedAsync</c> callback), so a handler must be non-blocking, must not do I/O
/// and must not await — the same constraint <see cref="MessageReceived"/> carries. A
/// throwing handler is caught and logged rather than escaping into the pump.
/// </para>
/// <para>
/// <b>Exactly once per edge, and only for a real outage.</b> It does not fire for a failed
/// <i>initial</i> connect (there was no session to lose — the values are still
/// <c>BadWaitingForInitialData</c>) nor during <see cref="DisposeAsync"/> (an intentional
/// teardown, which every redeploy performs). It is deliberately NOT the mirror of
/// <see cref="Reconnected"/>: recovery is a value the broker republishes, never an
/// assumption this connection makes on the caller's behalf.
/// </para>
/// </remarks>
public event Action? ConnectionLost;
/// <summary>
/// Raised for every inbound application message, on MQTTnet's own dispatcher thread. This
/// connection does no routing, parsing or typing of its own — that is the subscription
@@ -939,6 +964,19 @@ public sealed class MqttConnection : IAsyncDisposable, IMqttSubscribeTransport,
if (State == MqttConnectionState.Connected)
{
SetState(MqttConnectionState.Reconnecting);
// The quality edge (05/STAB-18): exactly here, once per outage, and never for an initial
// connect failure or a dispose — both of which are excluded by the state guard above.
// Contained, because this runs on the library's dispatcher: a throwing ingest handler must
// not stop the reconnect wake below.
try
{
ConnectionLost?.Invoke();
}
catch (Exception ex)
{
_logger?.LogError(ex, "MQTT driver '{DriverId}': a connection-lost handler threw.", _driverId);
}
}
// Released unconditionally rather than only when the client had been connected: a missed
@@ -96,6 +96,9 @@ public sealed class MqttSubscriptionManager
/// </summary>
private const uint StatusBadWaitingForInitialData = 0x80320000u;
/// <summary>The broker session this driver was observing is gone — see <see cref="OnConnectionLost"/>.</summary>
private const uint StatusBadNoCommunication = 0x80310000u;
private readonly int _defaultQos;
private readonly string _driverId;
private readonly ILogger? _logger;
@@ -237,6 +240,45 @@ public sealed class MqttSubscriptionManager
_transport = connection;
connection.MessageReceived += HandleMessage;
connection.Reconnected += OnReconnectedAsync;
connection.ConnectionLost += OnConnectionLost;
}
/// <summary>
/// Marks every already-observed reference <c>BadNoCommunication</c>, <b>keeping its last
/// value</b>, and publishes the change — the driver's answer to a lost broker session
/// (archreview 05/STAB-18 shape a). Wired to <see cref="MqttConnection.ConnectionLost"/> by
/// <see cref="AttachTo"/>; public so tests (and Sparkplug's ingestor) can drive the edge
/// directly.
/// </summary>
/// <remarks>
/// <para>
/// <b>This is what makes the reconnect recover.</b> The steady-state suppression in
/// <see cref="Deliver"/> compares value AND status against the driver-owned cache, which
/// survives reconnects and redeploys — so the retained replay of an unchanged payload was
/// suppressed and every node latched Bad by the host stayed Bad until the plant value
/// happened to change. Staling the status first makes that identical replay a change.
/// </para>
/// <para>
/// A never-observed reference is left alone (it is already
/// <see cref="StatusBadWaitingForInitialData"/>, which says something more precise), and an
/// already-Bad one is not re-stamped. Runs on MQTTnet's dispatcher thread: dictionary work
/// and synchronous <see cref="OnDataChange"/> invokes only — exactly what
/// <see cref="Deliver"/> already does there — and never awaits.
/// </para>
/// </remarks>
public void OnConnectionLost()
{
var now = DateTime.UtcNow;
foreach (var rawPath in _authored.ByRawPath.Keys)
{
var prior = Values.Read(rawPath);
if (prior.StatusCode == StatusBadWaitingForInitialData) continue; // never observed
if ((prior.StatusCode >> 30) >= 2) continue; // already Bad
var staled = prior with { StatusCode = StatusBadNoCommunication, ServerTimestampUtc = now };
Values.Update(rawPath, staled);
Raise(rawPath, staled);
}
}
/// <summary>