fix(mqtt): wildcard tags went dark on reconnect — index by filter, not topic
C1 (CRITICAL). AuthoredTable routed any wildcard-authored tag into `Wildcards`
and never into the concrete topic index. But the two paths that reconstruct
state from a SUBSCRIBED FILTER STRING — OnReconnectedAsync's re-subscribe set
and DegradeTopic — both looked up that concrete-only index, and
`_subscribedTopics` keys on the wildcard PATTERN ("f/+/temp"). So:
- reconnect resolved zero filters for a wildcard tag, hit a silent early
return, and issued NO subscribe. The cache keeps its last Good value, so the
tag never turns Bad — it just stops updating forever behind a connection
reporting healthy. Exactly the silent-death mode MqttConnection's own remarks
warn about, left unguarded for wildcards.
- DegradeTopic missed too, so a SUBACK rejection of a wildcard filter degraded
nothing and left the tag at BadWaitingForInitialData.
Fix: AuthoredTable now carries `ByFilter` (EVERY def, keyed by its authored
topic filter, wildcards included) alongside `ByExactTopic` (concrete only) and
`Wildcards`. Delivery matches an incoming published topic — which never carries
a wildcard — so it keeps using ByExactTopic + the comparer scan. Every path
keyed on a filter string uses ByFilter. The distinction is documented on the
record. The silent early return is now a Warning naming the orphaned topics.
Also in this pass:
- I2: bounded decode on the dispatcher thread. A body over `MaxPayloadBytes`
(default 1 MiB, ctor-settable) is refused BEFORE any GetString/JsonDocument
parse and degrades its own tags. Unbounded decode on the shared dispatcher is
paid by every subscription, not just the offending topic. NOTE: promoting this
to an operator-facing MqttDriverOptions key (+ WithMaximumPacketSize) needs a
.Contracts edit this task is scoped out of; flagged in the XML docs.
- I3: integral-valued reals now coerce to integer tags. JS/Python edge gateways
serialize an integer as 5.0, and TryGetInt32/int.TryParse are syntactic — an
Int32 tag fed by such a gateway silently never received data. Parsed via
decimal so integrality and range are exact across Int64/UInt64. A genuinely
fractional 5.5 is still refused, never rounded.
- I4: the JSON document is parsed ONCE per message and shared across the whole
fan-out (the documented "one document, one JSONPath per signal" shape was
re-parsing per tag). Zero-alloc via a ref struct when no Json tag matches.
- I5: pinned the documented JSON-string-holding-a-number coercion end-to-end.
- Minor: Register now prunes `_subscribedTopics` / `_handleByRawPath` entries for
tags a redeploy dropped, so a deleted topic stops being re-subscribed forever.
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
@@ -583,6 +583,250 @@ public sealed class MqttSubscriptionManagerTests
|
||||
mgr.Values.Read(OvenTempPath).StatusCode.ShouldBe(Good);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// C1 — wildcard-authored tags on the paths that reconstruct state from a FILTER string.
|
||||
// Delivery matches an incoming topic (concrete); reconnect + degrade key off the subscribed
|
||||
// filter, which for a wildcard tag is its PATTERN. Reading the concrete-only index there
|
||||
// silently drops the tag: no exception, no log, last Good value frozen forever.
|
||||
// ---------------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task OnReconnectedAsync_WildcardAuthoredTopic_IsReSubscribed()
|
||||
{
|
||||
var transport = new FakeTransport();
|
||||
var mgr = NewManager(transport);
|
||||
mgr.Register([Tag(OvenTempPath, """{"topic":"f/+/temp","payloadFormat":"Scalar","dataType":"Float64"}""")]);
|
||||
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
transport.Calls.Count.ShouldBe(1);
|
||||
|
||||
await mgr.OnReconnectedAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
transport.Calls.Count.ShouldBe(2);
|
||||
transport.Calls[1].Select(f => f.Topic).ShouldBe(["f/+/temp"]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The end-to-end shape of the defect: after a reconnect the wildcard tag must still receive
|
||||
/// data. A dropped re-subscribe is invisible in every other assertion — the cache keeps its last
|
||||
/// Good value, so nothing turns Bad.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task OnReconnectedAsync_WildcardAuthoredTopic_KeepsDeliveringAfterwards()
|
||||
{
|
||||
var transport = new FakeTransport();
|
||||
var mgr = NewManager(transport);
|
||||
mgr.Register([Tag(OvenTempPath, """{"topic":"f/+/temp","payloadFormat":"Scalar","dataType":"Float64"}""")]);
|
||||
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
|
||||
await mgr.OnReconnectedAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
// The broker only forwards what it was re-subscribed to; model that by asserting the filter is
|
||||
// live, then that a matching publish still lands.
|
||||
transport.Calls[^1].Select(f => f.Topic).ShouldContain("f/+/temp");
|
||||
|
||||
object? got = null;
|
||||
mgr.OnDataChange += (_, e) => got = e.Snapshot.Value;
|
||||
mgr.HandleMessage("f/oven/temp", "9.5"u8.ToArray(), retained: false);
|
||||
got.ShouldBe(9.5);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A total re-subscribe failure must throw for a wildcard-only driver too. Before the fix the
|
||||
/// wildcard tag never reached the filter set, so <c>filters.Count == 0</c> took a silent early
|
||||
/// return and the caller was told everything was fine.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task OnReconnectedAsync_WildcardOnly_TotalFailure_StillThrows()
|
||||
{
|
||||
var transport = new FakeTransport();
|
||||
var mgr = NewManager(transport);
|
||||
mgr.Register([Tag(OvenTempPath, """{"topic":"f/+/temp","payloadFormat":"Scalar","dataType":"Float64"}""")]);
|
||||
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
|
||||
transport.Throw = new InvalidOperationException("broker gone");
|
||||
|
||||
await Should.ThrowAsync<Exception>(() => mgr.OnReconnectedAsync(TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_WildcardAuthoredTopic_SubackRejection_DegradesItsRefs()
|
||||
{
|
||||
var transport = new FakeTransport { Reject = _ => true };
|
||||
var mgr = NewManager(transport);
|
||||
mgr.Register([Tag(OvenTempPath, """{"topic":"f/+/temp","payloadFormat":"Scalar","dataType":"Float64"}""")]);
|
||||
|
||||
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
|
||||
// Before the fix this stayed at BadWaitingForInitialData — the broker refused the subscription
|
||||
// and the tag never learned it was unreachable.
|
||||
mgr.Values.Read(OvenTempPath).StatusCode.ShouldBe(BadCommunicationError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OnReconnectedAsync_WildcardAuthoredTopic_PartialRejection_DegradesTheWildcardRefs()
|
||||
{
|
||||
var transport = new FakeTransport();
|
||||
var mgr = NewManager(transport);
|
||||
mgr.Register(
|
||||
[
|
||||
Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}"""),
|
||||
Tag(OvenPressPath, """{"topic":"f/+/press","payloadFormat":"Scalar","dataType":"Float64"}"""),
|
||||
]);
|
||||
await mgr.SubscribeAsync([OvenTempPath, OvenPressPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
mgr.HandleMessage(OvenTopic, "1"u8.ToArray(), retained: false);
|
||||
|
||||
transport.Reject = t => t == "f/+/press";
|
||||
|
||||
await Should.NotThrowAsync(() => mgr.OnReconnectedAsync(TestContext.Current.CancellationToken));
|
||||
|
||||
mgr.Values.Read(OvenPressPath).StatusCode.ShouldBe(BadCommunicationError);
|
||||
mgr.Values.Read(OvenTempPath).StatusCode.ShouldBe(Good);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// I2 — bounded decode on the dispatcher thread
|
||||
// ---------------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task HandleMessage_PayloadOverTheCeiling_IsRefusedBeforeDecode()
|
||||
{
|
||||
var mgr = new MqttSubscriptionManager(
|
||||
new MqttDriverOptions { Mode = MqttMode.Plain }, "mqtt-1", maxPayloadBytes: 16);
|
||||
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"String"}""")]);
|
||||
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
|
||||
DataValueSnapshot? snap = null;
|
||||
mgr.OnDataChange += (_, e) => snap = e.Snapshot;
|
||||
|
||||
mgr.HandleMessage(OvenTopic, new byte[17], retained: false);
|
||||
snap!.StatusCode.ShouldBe(BadDecodingError);
|
||||
|
||||
// …and a body at the ceiling still flows.
|
||||
mgr.HandleMessage(OvenTopic, "0123456789ABCDEF"u8.ToArray(), retained: false);
|
||||
snap.StatusCode.ShouldBe(Good);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MaxPayloadBytes_NonPositive_FallsBackToTheDefault() =>
|
||||
new MqttSubscriptionManager(new MqttDriverOptions(), "d", maxPayloadBytes: 0)
|
||||
.MaxPayloadBytes.ShouldBe(MqttSubscriptionManager.DefaultMaxPayloadBytes);
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// I3 — integral-valued reals against integer tags
|
||||
// ---------------------------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData("Int32", """{"value":5.0}""", 5)]
|
||||
[InlineData("Int32", """{"value":5}""", 5)]
|
||||
[InlineData("Int64", """{"value":5.0}""", 5L)]
|
||||
[InlineData("Int16", """{"value":5.0}""", (short)5)]
|
||||
[InlineData("UInt32", """{"value":5.0}""", 5u)]
|
||||
public async Task HandleMessage_Json_IntegralValuedReal_CoercesToIntegerTag(
|
||||
string dataType, string payload, object expected)
|
||||
{
|
||||
var mgr = NewManager();
|
||||
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Json","jsonPath":"$.value","dataType":"{{dataType}}"}""")]);
|
||||
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
|
||||
DataValueSnapshot? snap = null;
|
||||
mgr.OnDataChange += (_, e) => snap = e.Snapshot;
|
||||
|
||||
mgr.HandleMessage(OvenTopic, System.Text.Encoding.UTF8.GetBytes(payload), retained: false);
|
||||
|
||||
snap!.StatusCode.ShouldBe(Good);
|
||||
snap.Value.ShouldBe(expected);
|
||||
}
|
||||
|
||||
/// <summary>A genuinely fractional value is still refused — never rounded into a wrong value.</summary>
|
||||
[Fact]
|
||||
public async Task HandleMessage_Json_FractionalReal_StillRefusedByIntegerTag()
|
||||
{
|
||||
var mgr = NewManager();
|
||||
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Json","jsonPath":"$.value","dataType":"Int32"}""")]);
|
||||
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
|
||||
DataValueSnapshot? snap = null;
|
||||
mgr.OnDataChange += (_, e) => snap = e.Snapshot;
|
||||
|
||||
mgr.HandleMessage(OvenTopic, """{"value":5.5}"""u8.ToArray(), retained: false);
|
||||
|
||||
snap!.StatusCode.ShouldBe(BadTypeMismatch);
|
||||
snap.Value.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleMessage_Json_OutOfRangeIntegralReal_IsRefused()
|
||||
{
|
||||
var mgr = NewManager();
|
||||
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Json","jsonPath":"$.value","dataType":"Int16"}""")]);
|
||||
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
|
||||
DataValueSnapshot? snap = null;
|
||||
mgr.OnDataChange += (_, e) => snap = e.Snapshot;
|
||||
|
||||
mgr.HandleMessage(OvenTopic, """{"value":99999.0}"""u8.ToArray(), retained: false);
|
||||
|
||||
snap!.StatusCode.ShouldBe(BadTypeMismatch);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleMessage_Scalar_IntegralValuedReal_CoercesToIntegerTag()
|
||||
{
|
||||
var mgr = NewManager();
|
||||
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Int32"}""")]);
|
||||
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
|
||||
DataValueSnapshot? snap = null;
|
||||
mgr.OnDataChange += (_, e) => snap = e.Snapshot;
|
||||
|
||||
mgr.HandleMessage(OvenTopic, "5.0"u8.ToArray(), retained: false);
|
||||
|
||||
snap!.StatusCode.ShouldBe(Good);
|
||||
snap.Value.ShouldBe(5);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// I5 — a JSON string holding a number, against a numeric tag (documented as supported)
|
||||
// ---------------------------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData("Float64", """{"value":"21.5"}""", 21.5)]
|
||||
[InlineData("Int32", """{"value":"5"}""", 5)]
|
||||
[InlineData("Boolean", """{"value":"true"}""", true)]
|
||||
public async Task HandleMessage_Json_StringHoldingANumber_CoercesToTheNumericTag(
|
||||
string dataType, string payload, object expected)
|
||||
{
|
||||
var mgr = NewManager();
|
||||
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Json","jsonPath":"$.value","dataType":"{{dataType}}"}""")]);
|
||||
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
|
||||
DataValueSnapshot? snap = null;
|
||||
mgr.OnDataChange += (_, e) => snap = e.Snapshot;
|
||||
|
||||
mgr.HandleMessage(OvenTopic, System.Text.Encoding.UTF8.GetBytes(payload), retained: false);
|
||||
|
||||
snap!.StatusCode.ShouldBe(Good);
|
||||
snap.Value.ShouldBe(expected);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// Stale-state pruning on redeploy
|
||||
// ---------------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Register_DroppingATag_StopsItsTopicBeingReSubscribedForever()
|
||||
{
|
||||
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.Register([]); // redeploy drops the tag
|
||||
await mgr.OnReconnectedAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
transport.Calls.Count.ShouldBe(1); // the initial subscribe only — no zombie re-subscribe
|
||||
}
|
||||
|
||||
/// <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