merge: MQTT browser+contracts review findings resolved (Driver.Mqtt.Browser-002..-005, Driver.Mqtt.Contracts-004..-007)

This commit is contained in:
Joseph Doherty
2026-08-10 08:23:16 -04:00
15 changed files with 859 additions and 62 deletions
+72 -9
View File
@@ -7,7 +7,7 @@
| Review date | 2026-08-10 |
| Commit reviewed | `b990adae` |
| Status | Reviewed |
| Open findings | 4 |
| Open findings | 0 |
## Scope
@@ -86,7 +86,7 @@ four wildcard shapes, each asserting `PublishCountForTest == 0`.
| Severity | Low |
| Category | Correctness & logic bugs |
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttBrowseSession.cs:469` |
| Status | Open |
| Status | Resolved |
**Description:** The plain-mode and Sparkplug-mode paths disagree about what may become a
tree label. `ObserveSparkplugBirth` runs every group/edge-node/device/metric identifier
@@ -109,7 +109,26 @@ marker. The plain-mode marker text would need widening from "Some topics were to
show" to cover malformed names — the Sparkplug branch already words its marker that way
("oversized or malformed names"), so the two can converge.
**Resolution:** _(open)_
**Resolution (2026-08-10):** Fixed in `eddf5f57` — `ObservePlainTopic`'s pre-pass now rejects a
segment carrying a control character as well as an oversized one, and the rule is single-sourced
in a new `HasControlCharacter` that `IsUsableLabel` also calls, so the two ingest paths cannot
drift again. The plain marker converges on the Sparkplug wording ("oversized or malformed
names"). Pinned by
`MqttBrowseSessionTests.PlainTopicSegmentWithAControlCharacter_IsRejectedWhole_AndSurfacesAMarker`
(every segment well under `MaxSegmentChars`, so the length guard cannot be what passes it) plus
`PlainTopic_EachControlCharacterForm_IsRejected` over `\n` / `\r` / `\t` / ESC / NUL — 6 RED
before the fix — with `PlainTopic_PrintableNonAsciiSegments_AreStillAccepted` as the
falsification control: the rule is *control characters*, not "anything unusual", and a topic
segment legitimately carries accents, spaces and punctuation.
The second, smaller half — an **empty** topic level — was deliberately **not** converged with
`IsUsableLabel`'s non-empty rule, and `PlainTopic_WithAnEmptyLevel_IsStillRecorded` pins that
choice. `a//b` is legal MQTT and its terminal leaf is a real committable address (the commit
reads `AddressFields[topic]`, which carries the node's full path), so rejecting the whole topic
would cost the operator a legal address to tidy a blank row — a worse trade than the display
defect it fixes. The control-character half is the one that had to converge, because a label
carrying a newline breaks the row it renders in. The Sparkplug path has no equivalent case: every
id it labels came off `SparkplugTopic.TryParse`, which already rejects an empty segment.
### Driver.Mqtt.Browser-003
@@ -118,7 +137,7 @@ show" to cover malformed names — the Sparkplug branch already words its marker
| Severity | Low |
| Category | Concurrency & thread safety |
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttBrowseSession.cs:685` |
| Status | Open |
| Status | Resolved |
**Description:** `GetOrCreate` reads `_nodeCount` and compares it to `_nodeCap`, then
increments after a successful `GetOrAdd`. The read and the increment are not atomic with
@@ -138,7 +157,22 @@ pair with a single `Interlocked.Increment` compared against the cap, decrementin
lost-race path (`!ReferenceEquals(child, created)`). Until then, a comment stating the
single-dispatcher assumption the current shape relies on would be enough.
**Resolution:** _(open)_
**Resolution (2026-08-10):** Fixed in `eddf5f57` rather than commented — the recommendation's
own first option. `GetOrCreate` reserves the slot with a single `Interlocked.Increment` compared
against the cap and gives it back on **both** paths that do not consume it (the cap trip, and the
lost `GetOrAdd` race), so the count is exact rather than merely bounded. Pinned by
`MqttBrowseSessionTests.NodeCap_HoldsUnderConcurrentObservation`, which drives
`2 × ProcessorCount` dedicated threads through a `Barrier` at a cap of **1**, over 40 rounds:
against the read-then-increment shape it overshot to **31** nodes on round 0, and it stays at 1
with the fix (falsifiability re-checked by reverting the production change after the test was
made fast). Dedicated threads rather than `Task.Run` on purpose — every worker blocks on the
barrier, and blocking that many pool threads makes the pool inject on its ~500 ms starvation
timer, which both slowed the test to 23 s and staggered the very arrivals it aligns.
The second race the entry notes (a node visible in `Children` before `_byPath`) is unchanged and
still accepted: `ExpandAsync` returning empty for a node the tree already holds self-heals on the
next call, and the class remarks say so. The comment the recommendation offered as the minimum is
now moot — the shape no longer rests on a single-dispatcher assumption at all.
### Driver.Mqtt.Browser-004
@@ -147,7 +181,7 @@ single-dispatcher assumption the current shape relies on would be enough.
| Severity | Low |
| Category | Correctness & logic bugs |
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttBrowseSession.cs:1079` |
| Status | Open |
| Status | Resolved |
**Description:** `Project` orders a level by `n.Segment`, but the dictionary is keyed by
`n.Path` — and the whole reason for that keying (documented on `GetOrCreate`) is that in
@@ -167,7 +201,20 @@ and/or order folders before leaves so a device and a same-named metric are visua
separated. Either is a one-line change; a test observing a device and an edge-node metric of
the same name would pin it.
**Resolution:** _(open)_
**Resolution (2026-08-10):** Fixed in `eddf5f57` — `Project` now sorts
`.OrderBy(Segment).ThenBy(Path, Ordinal)`, making the order total. The folders-before-leaves
variant was not taken: the path tiebreaker already separates them (Ordinal puts `/` (0x2F) before
`:` (0x3A), so a device folder precedes its same-named node-level metric) without introducing a
second ordering concept. `Project`'s comment now states the reason instead of the over-claim the
entry quotes.
⚠️ **The single-pair test the recommendation suggests passes vacuously**, and did: .NET string
hashing is randomised per process, so the tie order a segment-only sort falls through to is
whatever *that run's* `ConcurrentDictionary` yields — with one pair it lands the right way round
about half the time, and the first attempt at this test went GREEN against the unfixed code.
`SparkplugTree_SiblingsSharingALabel_AreOrderedDeterministically` therefore uses **24** colliding
device/metric pairs, observed metric-first, and asserts the whole level: it failed 3/3 runs
against the unfixed sort and passes with it.
### Driver.Mqtt.Browser-005
@@ -176,7 +223,7 @@ the same name would pin it.
| Severity | Low |
| Category | Performance & resource management |
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttBrowseSession.cs:59`, `:81` |
| Status | Open |
| Status | Resolved |
**Description:** The per-session memory ceiling is higher than the constants suggest at a
glance. `DefaultNodeCap` is 50,000 nodes, and each `TopicNode` retains its full `Path`
@@ -197,4 +244,20 @@ tree"). What is missing is any bound on the *aggregate* across sessions.
is short enough to matter; if it does not, cap there rather than lowering `DefaultNodeCap`,
which would make the picker less useful on a genuinely large broker.
**Resolution:** _(open)_
**Resolution (2026-08-10):** Resolved as a **documentation change with no behaviour change**, in
`eddf5f57` — the recommendation's own verdict ("no code change needed in this module").
The confirmation it asks for was done, and the answer is **no**: `BrowseSessionRegistry` is a bare
`ConcurrentDictionary` that caps neither total sessions nor sessions per user. What bounds the
aggregate is **lifetime** — `BrowseSessionReaper` evicts and disposes anything untouched for a
2-minute `IdleTtl` on a 30-second tick, and `DisposeAsync` clears both maps — so the ceiling is
"sessions opened within a 2-minute window", not unbounded accumulation. That is short enough to
matter, so no cap was added; capping the session count there stays the right lever if one is ever
needed, rather than lowering `DefaultNodeCap`.
`DefaultNodeCap`'s doc comment now carries the honest per-session arithmetic (each node retains
its full `Path`, its `Segment` and a snippet, plus its own child dictionary, and every node is
held twice — so a session near the ceilings costs on the order of 100 MB, not the few MB "50 000
nodes" suggests), names the `#`-with-no-`TopicPrefix` open as the shape that approaches it, and
records where a future bound belongs. No test accompanies this: nothing behavioural changed, and
a test over a constant's doc comment would pin prose.
+113 -9
View File
@@ -7,7 +7,7 @@
| Review date | 2026-08-10 |
| Commit reviewed | `b990adae` |
| Status | Reviewed |
| Open findings | 4 |
| Open findings | 0 |
## Scope
@@ -162,7 +162,7 @@ deliberately excludes `metricName`); pinned by
| Severity | Low |
| Category | Documentation & comments |
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagDefinitionFactory.cs:124`, `:234`, `:476` |
| Status | Open |
| Status | Resolved |
**Description:** `retainSeed` is the one behavioural key still read leniently.
`ReadBoolOrDefault` returns the default for any non-boolean JSON value, so
@@ -186,7 +186,36 @@ so both gates stay in step; or (b) add an `Inspect` advisory naming the key and
default; or (c) at minimum document the deliberate exception on `ReadBoolOrDefault`.
Option (b) is lowest-risk — it changes no verdict.
**Resolution:** _(open)_
**Resolution (2026-08-10):** Fixed in `93f70504` — option (a), the strict read, chosen over the
"lowest-risk" advisory. The #549–#559 arc's settled contract is that **the parse is the accept
authority** and advisories ride blobs the parse has already ACCEPTED; an advisory here would have
left the driver still serving the inverse of what was authored, and the review's own reason for
recording rather than fixing ("the two surfaces are currently consistent") is satisfied without a
hand-mirrored rule — see below. Every sibling behavioural key in this factory (`payloadFormat`,
`dataType`, `qos`) is already strict, and `retainSeed` is the sharper case of the two: a defaulted
`qos` is a weaker delivery guarantee, whereas a defaulted `retainSeed` is the **inverse** of the
authored intent whenever the author wrote `false` — the only value worth writing at all.
Both mappers now read it through a new `TryReadBoolStrict`, mirroring `TryReadQosStrict`'s
absent / valid / present-but-invalid split, and `DescribeRejection` gained a matching
`DescribeInvalidBool` branch (a rejection, never an advisory: unlike `payloadFormat`, both mappers
read the key, so it can never land on an accepted blob).
**No second gate was bolted on outside Parse, and `MqttTagConfigModel` needed no mirrored rule.**
Its `Validate()` already ends in `TagConfigParseGate.Describe`, which asks *this* parse about the
blob the model was loaded from and words the refusal from *this* `Inspect` — so tightening the
factory tightened the authoring surface in the same change, with no drift surface created. The
model's `RetainSeed` doc now records that its own load stays lenient (a non-boolean reads as
"unset") and why that is safe.
Pinned by `MqttTagDefinitionFactoryTests.FromTagConfig_MalformedRetainSeed_RejectsStrict` /
`FromSparkplugTagConfig_MalformedRetainSeed_RejectsStrict` (over `"false"`, `"true"`, `0`, `1`,
`"yes"`, `null`), `Inspect_MalformedRetainSeed_Warns`, and — as the falsification control that a
reader simply refusing every blob carrying the key would fail —
`FromTagConfig_WellFormedRetainSeed_IsStillHonoured`. 11 RED before the fix. The
authoring↔deploy parity is pinned separately, on the corpus that would catch a future divergence:
`TagConfigNonEnumParityTests.Mqtt_refuses_a_retainSeed_its_reader_refuses` and its paired
`Mqtt_accepts_a_retainSeed_its_reader_accepts`.
### Driver.Mqtt.Contracts-005
@@ -195,7 +224,7 @@ Option (b) is lowest-risk — it changes no verdict.
| Severity | Low |
| Category | Testing coverage |
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttDriverOptions.cs:25`, `:71`, `:75`, `:79`, `:83`, `:99`, `:229` |
| Status | Open |
| Status | Resolved |
**Description:** `MqttDriverOptions` / `MqttPlainOptions` carry seven
`System.ComponentModel.DataAnnotations` `[Range]` attributes and **nothing ever runs them**.
@@ -221,7 +250,35 @@ checks the probe and the AdminUI form already carry. If neither, note on each at
that it is descriptive. The enforcing consumer (`MqttSubscriptionManager.BuildFilters`)
lives in `Driver.Mqtt`, outside this module.
**Resolution:** _(open)_
**Resolution (2026-08-10):** Fixed in `93f70504` — the attributes were **deleted**, not wired up,
and the one consequence that actually escaped was closed at the enforcing consumer.
**Wiring them would have broken deliberate behaviour**, which settles the "either/or" the
recommendation leaves open. Two of the seven *contradicted* the real authority: `[Range(1, …)]`
on `KeepAliveSeconds` forbids the **zero** `MqttDriver.Normalize` explicitly admits ("zero
keep-alive is legal MQTT; only a negative one is nonsense"), and the same attribute on
`MaxPayloadBytes` forbids the non-positive value this type's own remarks document as "falls back
to the driver's own 1 MiB default". Running the annotations would have refused two configurations
the driver is written to accept — the "two disagreeing authorities" outcome, arrived at by
enforcement rather than by neglect. So the bounds stay where they are applied
(`MqttDriver.Normalize`, `MqttSubscriptionManager`'s constructor, `MqttConnection.NextBackoff`),
each property names its own, and the type remarks record why there are no `DataAnnotations` here
and why adding one would be documentation that reads like enforcement.
**`MqttPlainOptions.DefaultQos` — the escape the entry identifies — is now clamped** in
`MqttSubscriptionManager`'s constructor, the same single enforcement point that already bounds
`MaxPayloadBytes`. Clamp rather than fall back to the default, deliberately: it matches what
`MqttDriverFormModel.Normalize` already persists (`Math.Clamp(DefaultQos, 0, 2)`), so the
authoring surface and the driver cannot reach different answers for one authored number. Pinned
by `MqttSubscriptionManagerTests.DefaultQos_OutsideTheLegalRange_NeverReachesTheSubscribe`, which
asserts the QoS on the **fake transport's actual SUBSCRIBE** rather than on a property — `5 ⇒ 2`
and `-1 ⇒ 0` were RED, with `0 ⇒ 0` / `2 ⇒ 2` green throughout as the controls.
Scope note: this closes the MQTT half only. The entry's observation that `[Range]` on driver
options is a **repo-wide** convention (`ModbusDriverOptions`, `S7DriverOptions`) still stands, and
those attributes are still unenforced — this change does not touch them, and the evidence above
argues the fleet-wide answer is deletion plus real bounds at each use site rather than a
composition-seam `Validator.TryValidateObject`.
### Driver.Mqtt.Contracts-006
@@ -230,7 +287,7 @@ lives in `Driver.Mqtt`, outside this module.
| Severity | Low |
| Category | Correctness & logic bugs |
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttWriteEncoder.cs:281` |
| Status | Open |
| Status | Resolved |
**Description:** Two edge cases in the `DriverDataType.DateTime` write arm. First,
`(ulong)…ToUnixTimeMilliseconds()` on a pre-1970 value wraps a negative `long` into a very
@@ -250,7 +307,27 @@ negative, and convert with an explicit `DateTime.SpecifyKind(…, DateTimeKind.U
`Unspecified` input (or refuse it, which is more in keeping with the file's own posture).
Add a test for each.
**Resolution:** _(open)_
**Resolution (2026-08-10):** Both fixed in `93f70504`.
The epoch bound: `ToUnixTimeMilliseconds()` is now taken into a `long`, and a negative one returns
`ValueNotRepresentable` before anything is written to the metric — Sparkplug's `DateTime` is an
*unsigned* ms-since-epoch, so a pre-1970 instant has no representation at all and the surrounding
"refused, never coerced" rule applies unchanged.
The kind: an `Unspecified` input is converted with `DateTime.SpecifyKind(…, DateTimeKind.Utc)`
rather than `ToUniversalTime()`. The entry offers refusal as "more in keeping with the file's own
posture", and it was not taken — `Convert.ToDateTime("2026-08-10T12:00:00")`, an ordinary authored
string, produces exactly this kind, so refusing would reject a perfectly reasonable write to fix a
determinism problem that reading it as UTC also fixes.
Pinned by `MqttWriteEncoderTests.A_pre_epoch_date_time_is_refused_rather_than_wrapped` (three
instants, incl. `DateTime.MinValue`) with `A_post_epoch_date_time_still_encodes_to_its_unix_milliseconds`
as the falsification control — a guard that refused *every* `DateTime` would satisfy the theory
alone — and `An_unspecified_kind_date_time_encodes_as_utc_not_as_node_local_time`, which asserts
the Unspecified and the same-instant-UTC encodings are byte-identical. ⚠️ That last one can only
go red on a host whose local timezone is not UTC (it was RED here under both `EDT` and
`TZ=Asia/Tokyo`, and is green under `TZ=UTC` either way) — which is the defect itself, and the
reason it is written as an equality between two encodings rather than against a hard-coded number.
### Driver.Mqtt.Contracts-007
@@ -259,7 +336,7 @@ Add a test for each.
| Severity | Low |
| Category | Error handling & resilience |
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/Sparkplug/SparkplugTopic.cs:202`, `:232` |
| Status | Open |
| Status | Resolved |
**Description:** `SparkplugTopic` validates asymmetrically. `TryParse` enforces
`IsValidSegment` (non-empty, no `+`/`#`) on every segment it reads, but `Format` and
@@ -279,4 +356,31 @@ succeeded, so the encoder's own guards must stay — `TryEncode` returns an erro
throwing, and that contract must not change. A round-trip property test
(`Parse(t.ToTopicString()) == t` over the parser's own accepted corpus) would pin it.
**Resolution:** _(open)_
**Resolution (2026-08-10):** Fixed in `93f70504` — `Format` and `FormatState` now apply the
parser's own `IsValidSegment` to `groupId` / `edgeNodeId` / `deviceId` / `hostId` through a shared
`ThrowIfNotASegment`, throwing `ArgumentException` as the existing contract does.
One deliberate addition beyond the recommendation's literal wording: the format-side check also
rejects **`/`**. `IsValidSegment` has no reason to test for one — `TryParse` has already split on
it — but on this side it is the difference between one id and two, and it is precisely the shape
the entry describes as "silently adds a segment". Without it the round-trip property the
recommendation asks for would not actually be total, since such a topic re-parses to nothing.
The caution the entry raises is respected: `MqttWriteEncoder`'s own guards stay, so `TryEncode`
still *returns* `NoWriteDestination` for these ids rather than propagating a throw, and that
contract is unchanged. The one production caller that can now throw where it previously produced
a malformed topic is `SparkplugPrimaryHost.StateTopic` (a wildcard `hostId`); it is reached only
from `MqttDriver.StartPrimaryHostAsync`, which is wrapped in a catch that logs a warning and
leaves ingest untouched by design — so the effect is "the host claim declines with a reason"
instead of "publish a protocol violation and lose the connection".
Pinned by `SparkplugTopicTests.Format_SegmentThatIsNotALegalTopicSegment_Throws` (9 cases:
`+`/`#` in each id, `/` in group and device, and an empty `deviceId`) and
`FormatState_HostIdThatIsNotALegalTopicSegment_Throws` — 9 RED before the fix — with
`Format_UnusualButLegalSegments_AreStillAccepted` as the falsification control (a Sparkplug id is
otherwise opaque: spaces, dots, dashes and accents are all legitimate, and a guard that refused
everything would satisfy the throw theories). The closure property is
`Format_Output_AlwaysReParsesToTheSameInstance`, asserted over the **formatter's own output**
across every non-STATE message type plus `FormatState`, rather than over a hand-written topic
list, so a future formatting change cannot leave the corpus describing a shape the type no longer
emits.
@@ -55,7 +55,27 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser;
/// </summary>
internal sealed class MqttBrowseSession : IRebirthCapableBrowseSession
{
/// <summary>Default cap on recorded nodes; a busy broker's <c>#</c> firehose is unbounded.</summary>
/// <summary>
/// Default cap on recorded nodes; a busy broker's <c>#</c> firehose is unbounded.
/// <para>
/// <b>Read this as a memory bound, not just a node count.</b> Each node retains its full
/// <c>Path</c> (up to <see cref="MaxTopicChars"/>), its <c>Segment</c> (up to
/// <see cref="MaxSegmentChars"/>) and, in plain mode, a snippet (up to
/// <see cref="PayloadSnippetMaxChars"/>), plus its own child dictionary — and every node is
/// held twice, once in its parent's children and once in the flat path index. So a session
/// near these ceilings costs on the order of 100 MB, not the few MB "50 000 nodes" suggests,
/// and a plain-mode open with no <c>TopicPrefix</c> subscribes <c>#</c> — the whole broker —
/// which is exactly the shape that approaches them.
/// </para>
/// <para>
/// <b>The aggregate across sessions is bounded by LIFETIME, not by a count.</b>
/// <c>BrowseSessionRegistry</c> caps neither concurrent sessions nor sessions per user;
/// <c>BrowseSessionReaper</c> evicts and disposes anything untouched for its 2-minute idle
/// TTL on a 30-second tick, and <c>DisposeAsync</c> clears both maps. If a per-session bound
/// is ever needed, cap the number of concurrent sessions there rather than lowering this
/// constant, which would make the picker less useful on a genuinely large broker.
/// </para>
/// </summary>
internal const int DefaultNodeCap = 50_000;
/// <summary>Maximum characters of a payload retained as the picker's confirm-it's-the-right-topic snippet.</summary>
@@ -198,8 +218,9 @@ internal sealed class MqttBrowseSession : IRebirthCapableBrowseSession
internal bool Truncated => Volatile.Read(ref _truncated) != 0;
/// <summary>
/// True once a topic was rejected for breaching <see cref="MaxTopicChars"/> /
/// <see cref="MaxSegmentChars"/>; surfaced as its own marker node from
/// True once a topic or metric was rejected as unusable — for breaching
/// <see cref="MaxTopicChars"/> / <see cref="MaxSegmentChars"/>, or for carrying a control
/// character in a segment / identifier; surfaced as its own marker node from
/// <see cref="RootAsync"/>. Kept distinct from <see cref="Truncated"/> because the operator's
/// remedy differs — narrow the prefix, versus this broker publishes absurd topic names.
/// </summary>
@@ -245,9 +266,11 @@ internal sealed class MqttBrowseSession : IRebirthCapableBrowseSession
{
nodes.Add(new BrowseNode(
OversizedNodeId,
// Both modes word it the same way: since the plain path rejects a control-character
// segment as well as an oversized one, "too long" would name only half the cause.
Mode == MqttMode.SparkplugB
? "⚠ Some metrics were unusable (oversized or malformed names) — use manual entry for those"
: "⚠ Some topics were too long to show — use manual entry for those",
: "⚠ Some topics were unusable (oversized or malformed names) — use manual entry for those",
BrowseNodeKind.Folder,
HasChildrenHint: false));
}
@@ -477,9 +500,21 @@ internal sealed class MqttBrowseSession : IRebirthCapableBrowseSession
var segments = topic.Split('/');
// Checked as a pre-pass so a rejected topic leaves no half-built path behind.
//
// The control-character half is the same rule IsUsableLabel applies to a Sparkplug identifier,
// and for the same reason: a segment becomes the picker's displayed label, MQTT forbids only
// U+0000 in a topic NAME, and an embedded newline or ANSI escape breaks a single-line row. It is
// a REJECTION rather than a scrub because a scrubbed segment is a different topic, and the
// picker would commit it as the tag's address.
//
// An EMPTY segment is deliberately NOT rejected, so this is not simply IsUsableLabel: `a//b` and
// a trailing '/' are legal MQTT, and such a topic's terminal leaf is a real committable address
// (the commit reads AddressFields[topic], which carries the node's full path). Refusing the whole
// topic would cost the operator a legal address to tidy a blank row. The Sparkplug path has no
// such case — every id it labels came off SparkplugTopic.TryParse, which rejects an empty segment.
foreach (var segment in segments)
{
if (segment.Length > MaxSegmentChars)
if (segment.Length > MaxSegmentChars || HasControlCharacter(segment))
{
Volatile.Write(ref _oversized, 1);
return;
@@ -682,8 +717,14 @@ internal sealed class MqttBrowseSession : IRebirthCapableBrowseSession
// different nodes, and keying by label would silently drop whichever arrived second.
if (level.TryGetValue(path, out var existing)) return existing;
if (Volatile.Read(ref _nodeCount) >= _nodeCap)
// The slot is RESERVED before the node is created, in one interlocked step, and given back on
// either path that does not consume it. Reading the count and then incrementing it would let N
// observers each see `cap - 1` and all insert, overshooting the cap by up to N-1 — the cap is
// the only bound between a `#` firehose and unbounded memory, so it must not depend on MQTTnet
// happening to deliver on a single dispatcher thread.
if (Interlocked.Increment(ref _nodeCount) > _nodeCap)
{
Interlocked.Decrement(ref _nodeCount);
Volatile.Write(ref _truncated, 1);
return null;
}
@@ -692,9 +733,12 @@ internal sealed class MqttBrowseSession : IRebirthCapableBrowseSession
var child = level.GetOrAdd(path, created);
if (ReferenceEquals(child, created))
{
Interlocked.Increment(ref _nodeCount);
_byPath[path] = child;
}
else
{
Interlocked.Decrement(ref _nodeCount); // lost the insert race; the reservation was not consumed
}
return child;
}
@@ -709,15 +753,24 @@ internal sealed class MqttBrowseSession : IRebirthCapableBrowseSession
/// </summary>
/// <param name="label">The candidate label.</param>
/// <returns><see langword="true"/> when the label may be recorded.</returns>
private static bool IsUsableLabel(string label)
private static bool IsUsableLabel(string label) =>
label.Length != 0 && label.Length <= MaxSegmentChars && !HasControlCharacter(label);
/// <summary>
/// Whether <paramref name="text"/> carries a character that would break the picker's single-line
/// rows. Single-sourced so the plain and Sparkplug ingest paths cannot drift on what may become a
/// tree label — see <see cref="ObservePlainTopic"/> for the one rule they deliberately differ on.
/// </summary>
/// <param name="text">The candidate label.</param>
/// <returns><see langword="true"/> when a control character is present.</returns>
private static bool HasControlCharacter(string text)
{
if (label.Length == 0 || label.Length > MaxSegmentChars) return false;
foreach (var c in label)
foreach (var c in text)
{
if (char.IsControl(c)) return false;
if (char.IsControl(c)) return true;
}
return true;
return false;
}
/// <summary>
@@ -1095,10 +1148,23 @@ internal sealed class MqttBrowseSession : IRebirthCapableBrowseSession
/// <summary>Projects one level of the tree into browse nodes, ordered so the picker is stable.</summary>
/// <param name="level">The child map to project.</param>
/// <returns>The projected nodes, sorted by segment.</returns>
/// <returns>The projected nodes, sorted by segment and then by path.</returns>
/// <remarks>
/// <b>The sort must be TOTAL, which is why the path is the tiebreaker.</b> Sorting by segment
/// alone is only a stable order when labels are unique, and the whole reason this map is keyed by
/// path (see <see cref="GetOrCreate"/>) is that in Sparkplug mode two siblings CAN share one —
/// a device folder <c>Plant1/EdgeA/Temp</c> and an edge-node-level metric
/// <c>Plant1/EdgeA::Temp</c>. <see cref="Enumerable.OrderBy{TSource, TKey}(IEnumerable{TSource},
/// Func{TSource, TKey}, IComparer{TKey})"/> is stable with respect to its input, but the input is
/// <see cref="ConcurrentDictionary{TKey, TValue}.Values"/>, whose enumeration order is not
/// guaranteed and can change as the tree grows — so a tie left to it lets two visually identical
/// rows swap places between successive <see cref="ExpandAsync"/> calls, and the operator cannot
/// see which one they are clicking.
/// </remarks>
private static List<BrowseNode> Project(ConcurrentDictionary<string, TopicNode> level) =>
level.Values
.OrderBy(n => n.Segment, StringComparer.Ordinal)
.ThenBy(n => n.Path, StringComparer.Ordinal)
.Select(n =>
{
// Plain mode infers: a childless node is always the terminal segment of a topic that
@@ -1,4 +1,3 @@
using System.ComponentModel.DataAnnotations;
using System.Text;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
@@ -15,14 +14,30 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
/// <c>OpcUaClientDriverOptions</c>. <see cref="Mode"/> selects the ingest shape;
/// <see cref="Sparkplug"/> and <see cref="Plain"/> are nullable sub-objects — only the one
/// matching <see cref="Mode"/> is populated.
/// <para>
/// ⚠️ <b>There are deliberately no <c>DataAnnotations</c> range attributes on this DTO, and adding
/// any would be documentation that reads like enforcement.</b> The options are bound by
/// <c>JsonSerializer.Deserialize</c> through <see cref="MqttJson.Options"/>, never by
/// <c>IOptions</c> + <c>ValidateDataAnnotations</c>, and nothing in the four MQTT projects calls
/// <c>Validator.TryValidateObject</c> — so the seven <c>[Range]</c>s this type used to carry never
/// ran once. Worse, two of them <i>contradicted</i> the real authority: <c>[Range(1, …)]</c> on
/// <see cref="KeepAliveSeconds"/> forbade the zero that <c>MqttDriver.Normalize</c> deliberately
/// admits as legal MQTT, and the same on <see cref="MaxPayloadBytes"/> forbade the non-positive
/// value this type's own remarks document as "falls back to the 1 MiB default". Wiring them up
/// would therefore have broken deliberate behaviour, so the bounds live where they are actually
/// applied — <c>MqttDriver.Normalize</c>, <c>MqttSubscriptionManager</c>'s constructor and
/// <c>MqttConnection.NextBackoff</c> — and each property below names its own.
/// </para>
/// </remarks>
public sealed record MqttDriverOptions
{
/// <summary>Broker hostname or IP address.</summary>
public string Host { get; init; } = "localhost";
/// <summary>Broker TCP port.</summary>
[Range(1, 65535)]
/// <summary>
/// Broker TCP port. Range-checked by <c>MqttDriverProbe</c> and the AdminUI driver form; there is
/// no <c>[Range]</c> attribute here because nothing would ever run it — see the type remarks.
/// </summary>
public int Port { get; init; } = 8883;
/// <summary>
@@ -67,20 +82,22 @@ public sealed record MqttDriverOptions
/// <summary>Whether to request a clean session (v3.1.1) / clean start (v5.0) at CONNECT.</summary>
public bool CleanSession { get; init; } = true;
/// <summary>Keep-alive interval sent at CONNECT.</summary>
[Range(1, int.MaxValue)]
/// <summary>
/// Keep-alive interval sent at CONNECT. Bounded by <c>MqttDriver.Normalize</c>, which admits
/// <b>zero</b> — legal MQTT for "no keep-alive" — and only replaces a negative value.
/// </summary>
public int KeepAliveSeconds { get; init; } = 30;
/// <summary>Bounded connect deadline (see design §8) — the driver never hangs past this.</summary>
[Range(1, int.MaxValue)]
/// <summary>
/// Bounded connect deadline (see design §8) — the driver never hangs past this. A non-positive
/// value is replaced by <c>MqttDriver.Normalize</c>, which is the enforcement point.
/// </summary>
public int ConnectTimeoutSeconds { get; init; } = 15;
/// <summary>Initial reconnect backoff after a connection drop.</summary>
[Range(1, int.MaxValue)]
/// <summary>Initial reconnect backoff after a connection drop; clamped by <c>MqttConnection.NextBackoff</c>.</summary>
public int ReconnectMinBackoffSeconds { get; init; } = 1;
/// <summary>Cap on the exponential reconnect backoff.</summary>
[Range(1, int.MaxValue)]
/// <summary>Cap on the exponential reconnect backoff; clamped by <c>MqttConnection.NextBackoff</c>.</summary>
public int ReconnectMaxBackoffSeconds { get; init; } = 30;
/// <summary>
@@ -96,7 +113,6 @@ public sealed record MqttDriverOptions
/// because this <c>.Contracts</c> assembly is <i>referenced by</i> the driver assembly and
/// cannot reference it back; the manager's constructor is the single enforcement point.
/// </remarks>
[Range(1, int.MaxValue)]
public int MaxPayloadBytes { get; init; } = 1024 * 1024;
/// <summary>Selects the ingest shape — Plain topics or Sparkplug B.</summary>
@@ -208,7 +224,6 @@ public sealed record MqttSparkplugOptions
/// How long, in seconds, browse/discovery collects NBIRTH/DBIRTH traffic before
/// considering the observed metric set stable.
/// </summary>
[Range(1, int.MaxValue)]
public int BirthObservationWindowSeconds { get; init; } = 15;
}
@@ -225,7 +240,10 @@ public sealed record MqttPlainOptions
/// </summary>
public string TopicPrefix { get; init; } = "";
/// <summary>Default QoS used when a tag's own <c>qos</c> is unset.</summary>
[Range(0, 2)]
/// <summary>
/// Default QoS used when a tag's own <c>qos</c> is unset. MQTT defines exactly three levels;
/// <c>MqttSubscriptionManager</c>'s constructor clamps this to 0–2 before it can reach a
/// SUBSCRIBE, matching what the AdminUI driver form persists.
/// </summary>
public int DefaultQos { get; init; } = 1;
}
@@ -61,10 +61,11 @@ public static class MqttTagDefinitionFactory
/// and framing is decided solely by <c>JsonDocument.Parse</c> plus the
/// root-<see cref="JsonValueKind.Object"/> check — so leading whitespace is skipped exactly as the JSON
/// spec says, and a padded blob reaches the same verdict its unpadded form does (Gitea #559).
/// Enum fields (<c>payloadFormat</c> / <c>dataType</c>) and <c>qos</c> are read STRICTLY — a
/// present-but-invalid (typo'd) value rejects the whole tag (returns <see langword="false"/> ⇒ the
/// driver surfaces <c>BadNodeIdUnknown</c>) rather than silently defaulting to a wrong-typed Good or
/// a downgraded delivery guarantee. Never throws.
/// Enum fields (<c>payloadFormat</c> / <c>dataType</c>), <c>qos</c> and <c>retainSeed</c> are read
/// STRICTLY — a present-but-invalid (typo'd) value rejects the whole tag (returns
/// <see langword="false"/> ⇒ the driver surfaces <c>BadNodeIdUnknown</c>) rather than silently
/// defaulting to a wrong-typed Good, a downgraded delivery guarantee, or the inverse of the
/// authored seeding behaviour. Never throws.
/// <para>
/// A <b>wildcard</b> topic (<c>+</c> / <c>#</c>) is deliberately NOT rejected here: it is
/// ambiguous rather than unparseable, and <see cref="Inspect"/> is the operator-visible surface
@@ -119,6 +120,12 @@ public static class MqttTagDefinitionFactory
// WEAKER delivery guarantee than the one they authored, with nothing to show for it.
if (!TryReadQosStrict(root, out var qos)) return false;
// …and so is retainSeed, the last behavioural key that was not. A lenient read turned
// "retainSeed":"false" — which a CSV import or a hand-edited blob produces naturally — into
// the default TRUE, the OPPOSITE of what was authored, and the tag then seeded its initial
// value from the broker's retained message the author asked it to ignore.
if (!TryReadBoolStrict(root, "retainSeed", defaultValue: true, out var retainSeed)) return false;
var jsonPath = ReadString(root, "jsonPath");
if (string.IsNullOrEmpty(jsonPath)) jsonPath = RootJsonPath;
@@ -129,7 +136,7 @@ public static class MqttTagDefinitionFactory
JsonPath: jsonPath,
DataType: dataType,
Qos: qos,
RetainSeed: ReadBoolOrDefault(root, "retainSeed", defaultValue: true),
RetainSeed: retainSeed,
DataTypeAuthored: dataTypeAuthored,
// Optional, and absence is meaningful: no writeTopic ⇒ not writable (Gitea #508).
// Blank normalises to null so "" and absent cannot mean different things.
@@ -230,6 +237,10 @@ public static class MqttTagDefinitionFactory
if (!TryReadQosStrict(root, out var qos)) return false;
// Same key, same strictness — retainSeed is read identically by both shapes, so a blob's
// verdict on it must not depend on which reader took it.
if (!TryReadBoolStrict(root, "retainSeed", defaultValue: true, out var retainSeed)) return false;
def = new MqttTagDefinition(
Name: rawPath,
// Plain-shape fields carried through verbatim; the Sparkplug ingest path reads none of
@@ -239,7 +250,7 @@ public static class MqttTagDefinitionFactory
JsonPath: RootJsonPath,
DataType: dataType,
Qos: qos,
RetainSeed: ReadBoolOrDefault(root, "retainSeed", defaultValue: true),
RetainSeed: retainSeed,
DataTypeAuthored: dataTypeAuthored,
GroupId: groupId,
EdgeNodeId: edgeNodeId,
@@ -267,7 +278,7 @@ public static class MqttTagDefinitionFactory
/// <item>
/// <b>Rejections</b> — a blob NEITHER runtime mapper would read: an unparseable or
/// non-object document, present-but-invalid <c>payloadFormat</c> / <c>dataType</c> /
/// <c>qos</c> values (all read strictly), or an address that names neither a Plain
/// <c>qos</c> / <c>retainSeed</c> values (all read strictly), or an address that names neither a Plain
/// <c>topic</c> nor a complete Sparkplug <c>groupId</c>/<c>edgeNodeId</c>/<c>metricName</c>
/// tuple. Such a tag is skipped by the driver and reads <c>BadNodeIdUnknown</c> forever.
/// </item>
@@ -446,6 +457,9 @@ public static class MqttTagDefinitionFactory
TagConfigJson.DescribeInvalidEnum<MqttPayloadFormat>(root, "payloadFormat"),
TagConfigJson.DescribeInvalidEnum<DriverDataType>(root, "dataType"),
DescribeInvalidQos(root),
// Read strictly by BOTH mappers, so — unlike payloadFormat — it can never land on an
// accepted blob and is a rejection cause outright.
DescribeInvalidBool(root, "retainSeed"),
})
{
if (w is not null) rejections.Add(w);
@@ -515,8 +529,45 @@ public static class MqttTagDefinitionFactory
private static string? NullIfBlank(string value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static bool ReadBoolOrDefault(JsonElement o, string name, bool defaultValue)
=> o.TryGetProperty(name, out var e) && e.ValueKind is JsonValueKind.True or JsonValueKind.False
? e.GetBoolean()
: defaultValue;
/// <summary>
/// Strict boolean read, mirroring <see cref="TryReadQosStrict"/>'s absent / valid /
/// present-but-invalid split: absent ⇒ <paramref name="defaultValue"/>; a JSON <c>true</c>/
/// <c>false</c> ⇒ that value; anything else present (a string, a number, an explicit
/// <c>null</c>) ⇒ the read FAILS.
/// </summary>
/// <param name="o">The TagConfig root object.</param>
/// <param name="name">The property name to read.</param>
/// <param name="defaultValue">The value an ABSENT key takes.</param>
/// <param name="value">The parsed flag.</param>
/// <returns><see langword="false"/> only when the key is present but not a JSON boolean.</returns>
/// <remarks>
/// Lenient-with-a-default is the wrong shape for a BEHAVIOURAL flag, which is the whole
/// <c>qos</c> argument again: absorbing a malformed value into the default silently hands the
/// operator the opposite of what they authored, with nothing to show for it. It is worse here
/// than for <c>qos</c>, in fact — a defaulted <c>qos</c> is a weaker delivery guarantee, whereas
/// a defaulted <c>retainSeed</c> is the INVERSE of the authored intent whenever the author wrote
/// <c>false</c>, which is the only value worth writing at all.
/// </remarks>
private static bool TryReadBoolStrict(JsonElement o, string name, bool defaultValue, out bool value)
{
value = defaultValue;
if (!o.TryGetProperty(name, out var e)) return true; // absent
if (e.ValueKind is not (JsonValueKind.True or JsonValueKind.False)) return false;
value = e.GetBoolean();
return true;
}
/// <summary>
/// A human-readable warning for a present-but-invalid boolean field, or <see langword="null"/>
/// when it is absent or valid — the boolean counterpart of <see cref="DescribeInvalidQos"/>.
/// </summary>
/// <param name="o">The TagConfig root object.</param>
/// <param name="name">The property name to describe.</param>
/// <returns>The warning text, or <see langword="null"/>.</returns>
private static string? DescribeInvalidBool(JsonElement o, string name)
{
if (!o.TryGetProperty(name, out var e)) return null;
if (e.ValueKind is JsonValueKind.True or JsonValueKind.False) return null;
return $"value '{e.GetRawText()}' for '{name}' is not a JSON boolean; valid: true, false";
}
}
@@ -304,11 +304,29 @@ public static class MqttWriteEncoder
return MqttWriteEncodeError.None;
case DriverDataType.DateTime:
{
var authored = Convert.ToDateTime(value, CultureInfo.InvariantCulture);
// An Unspecified kind is read as UTC, not passed to ToUniversalTime() — that treats
// it as LOCAL, so the identical authored instant would encode differently on the two
// nodes of a redundant pair, which are not required to share a timezone. Reading it
// as UTC rather than refusing it keeps an ordinary authored "2026-08-10T12:00:00"
// writable: Convert.ToDateTime produces exactly that kind for one.
var utc = authored.Kind == DateTimeKind.Unspecified
? DateTime.SpecifyKind(authored, DateTimeKind.Utc)
: authored.ToUniversalTime();
// Sparkplug's DateTime is an UNSIGNED milliseconds-since-epoch, so a pre-1970 instant
// has no representation at all — the cast below would wrap a negative long into a
// timestamp ~584 million years in the future. Refuse it: unrepresentable values are
// refused, never coerced.
var unixMs = new DateTimeOffset(utc).ToUnixTimeMilliseconds();
if (unixMs < 0) return MqttWriteEncodeError.ValueNotRepresentable;
metric.Datatype = (uint)TahuDataType.DateTime;
metric.LongValue = (ulong)new DateTimeOffset(
Convert.ToDateTime(value, CultureInfo.InvariantCulture).ToUniversalTime())
.ToUnixTimeMilliseconds();
metric.LongValue = (ulong)unixMs;
return MqttWriteEncodeError.None;
}
default:
// DriverDataType.Reference and anything added later: refuse rather than guess a
@@ -195,10 +195,24 @@ public sealed record SparkplugTopic(
/// it does not have to hand-concatenate segments itself.
/// </summary>
/// <exception cref="ArgumentException">
/// <paramref name="groupId"/>/<paramref name="edgeNodeId"/> is null/empty, or
/// <paramref name="groupId"/>/<paramref name="edgeNodeId"/> is null/empty, any supplied id is
/// not a legal single topic segment (see <see cref="IsValidSegment"/>), or
/// <paramref name="type"/> is <see cref="SparkplugMessageType.STATE"/> (use
/// <see cref="FormatState"/> instead — STATE has no group/edge-node/device segments).
/// </exception>
/// <remarks>
/// <b>The per-segment rule is enforced here, not left to the caller</b>, so the type is
/// round-trip-closed: everything this emits re-parses through <see cref="TryParse"/> to the
/// instance it was built from. Without it, a <c>+</c>/<c>#</c> id produced a topic MQTT-3.3.2-2
/// forbids in a PUBLISH — a protocol violation a conformant broker answers by dropping the
/// connection — and a <c>deviceId</c> containing <c>/</c> silently added a segment, i.e. a
/// well-formed topic addressing a different device than the caller named.
/// <para>
/// ⚠️ This THROWS where it previously succeeded, so a caller that must not throw keeps its own
/// guard: <c>MqttWriteEncoder.TryEncode</c> returns <c>NoWriteDestination</c> for these ids
/// rather than propagating, and that contract does not change.
/// </para>
/// </remarks>
public static string Format(string groupId, SparkplugMessageType type, string edgeNodeId, string? deviceId = null)
{
ArgumentException.ThrowIfNullOrEmpty(groupId);
@@ -208,15 +222,26 @@ public sealed record SparkplugTopic(
throw new ArgumentException("Use FormatState to build a STATE topic.", nameof(type));
}
ThrowIfNotASegment(groupId, nameof(groupId));
ThrowIfNotASegment(edgeNodeId, nameof(edgeNodeId));
if (deviceId is not null) ThrowIfNotASegment(deviceId, nameof(deviceId));
return deviceId is null
? $"{Namespace}/{groupId}/{type}/{edgeNodeId}"
: $"{Namespace}/{groupId}/{type}/{edgeNodeId}/{deviceId}";
}
/// <summary>Builds the v3.0 STATE topic string <c>spBv1.0/STATE/{hostId}</c>.</summary>
/// <param name="hostId">The primary-host id; must be a legal single topic segment.</param>
/// <returns>The STATE topic string.</returns>
/// <exception cref="ArgumentException">
/// <paramref name="hostId"/> is null/empty or is not a legal single topic segment — see the
/// round-trip-closure remarks on <see cref="Format"/>.
/// </exception>
public static string FormatState(string hostId)
{
ArgumentException.ThrowIfNullOrEmpty(hostId);
ThrowIfNotASegment(hostId, nameof(hostId));
return $"{Namespace}/STATE/{hostId}";
}
@@ -288,4 +313,27 @@ public sealed record SparkplugTopic(
private static bool IsValidSegment(string segment) =>
segment.Length > 0 && segment.IndexOf('+') < 0 && segment.IndexOf('#') < 0;
/// <summary>
/// Applies <see cref="IsValidSegment"/> on the FORMAT side, so the parser and the formatter judge
/// an id by one rule — plus <c>/</c>, which only the format side can see.
/// </summary>
/// <param name="segment">The candidate segment.</param>
/// <param name="paramName">The argument name to report.</param>
/// <exception cref="ArgumentException">The segment is empty, carries an MQTT wildcard, or carries <c>/</c>.</exception>
/// <remarks>
/// <c>/</c> cannot appear in a parsed id — <see cref="TryParse"/> has already split on it — so
/// <see cref="IsValidSegment"/> has no reason to test for one and does not. On this side it is
/// the difference between an id and two ids: a formatted value carrying one produces a topic with
/// an extra segment, which addresses something other than what the caller named and no longer
/// re-parses at all. Rejecting it is what makes the round trip total.
/// </remarks>
private static void ThrowIfNotASegment(string segment, string paramName)
{
if (IsValidSegment(segment) && segment.IndexOf('/') < 0) return;
throw new ArgumentException(
$"'{segment}' is not a legal Sparkplug topic segment: it must be non-empty and carry no '/' "
+ "and no MQTT wildcard (+ or #).",
paramName);
}
}
@@ -65,6 +65,14 @@ public sealed class MqttSubscriptionManager
/// </summary>
public const int DefaultMaxPayloadBytes = 1024 * 1024;
/// <summary>
/// Subscription QoS applied when neither the tag nor the driver's <c>Plain</c> section authored
/// one. Mirrors <see cref="MqttPlainOptions.DefaultQos"/>' own default — duplicated rather than
/// referenced because that value is an instance property, and this is the fallback for the case
/// where the whole section is absent.
/// </summary>
public const int DefaultQos = 1;
// OPC UA status codes (values verified against Opc.Ua.StatusCodes, not recalled).
private const uint StatusGood = 0x00000000u;
@@ -137,7 +145,13 @@ public sealed class MqttSubscriptionManager
MaxPayloadBytes = maxPayloadBytes > 0 ? maxPayloadBytes : DefaultMaxPayloadBytes;
_mode = options.Mode;
_defaultQos = options.Plain?.DefaultQos ?? 1;
// Clamped here for the same reason MaxPayloadBytes is: this constructor is the single
// enforcement point for the driver options the manager captures, and MQTT defines exactly three
// QoS levels — an out-of-range value would otherwise ride into BuildFilters and out onto the
// SUBSCRIBE verbatim. Clamp rather than fall back to the default, matching what
// MqttDriverFormModel.Normalize already persists, so the authoring surface and the driver cannot
// reach different answers for the same authored number.
_defaultQos = Math.Clamp(options.Plain?.DefaultQos ?? DefaultQos, 0, 2);
_driverId = driverId;
_transport = transport;
_logger = logger;
@@ -119,6 +119,14 @@ public sealed class MqttTagConfigModel
/// the key and inherit the driver's default (<c>true</c>). An absent key stays absent. Shared by
/// both modes — <c>MqttTagDefinitionFactory</c> reads <c>retainSeed</c> identically for Plain and
/// Sparkplug.
/// <para>
/// The <b>load</b> is lenient (a non-boolean value reads as <c>null</c>, i.e. "unset"), but the
/// driver reads the key STRICTLY — <c>"retainSeed":"false"</c>, which a CSV import produces
/// naturally, rejects the whole tag rather than silently taking the default, which is the
/// INVERSE of that author's intent. No hand-mirrored rule is needed here for that:
/// <see cref="Validate"/>'s <see cref="TagConfigParseGate"/> consult asks the driver's own reader
/// about the loaded blob, so the save is blocked with the inspector's own wording.
/// </para>
/// </summary>
public bool? RetainSeed { get; set; }
@@ -296,6 +296,123 @@ public sealed class MqttBrowseSessionTests
(await s.RootAsync(TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
}
/// <summary>
/// The two ingest paths must agree on what may become a tree label. MQTT forbids only
/// <c>U+0000</c> in a topic NAME, so a publisher can legally put a newline or an ANSI escape in
/// one — and a plain segment goes straight into the picker's displayed label, exactly where the
/// Sparkplug path already rejects one.
/// </summary>
/// <summary>
/// The node cap must hold under concurrent observation. MQTTnet delivers on a single dispatcher
/// per client today, so this is a guard on the shape rather than a live defect — but the cap is
/// the only thing standing between a <c>#</c> firehose and unbounded memory, and a read-then-
/// increment pair lets N threads each see <c>cap - 1</c> and all insert.
/// </summary>
[Fact]
public async Task NodeCap_HoldsUnderConcurrentObservation()
{
// One round would pass by luck; the overshoot is a race. Each round drives many threads at a
// cap of 1 through a barrier, so every thread reaches the cap check at once.
const int rounds = 40;
var workers = Math.Max(Environment.ProcessorCount * 2, 8);
for (var round = 0; round < rounds; round++)
{
await using var s = new MqttBrowseSession(MqttMode.Plain, nodeCap: 1);
using var barrier = new Barrier(workers);
// Dedicated threads, not Task.Run: every worker BLOCKS on the barrier, and blocking that
// many pool threads makes the pool inject new ones on its ~500 ms starvation timer — which
// both slows the test enormously and staggers the very arrivals it is trying to align.
var threads = Enumerable.Range(0, workers)
.Select(i => new Thread(() =>
{
barrier.SignalAndWait();
s.ObserveTopicForTest($"t{i}", "1");
}) { IsBackground = true })
.ToList();
foreach (var t in threads) t.Start();
foreach (var t in threads) t.Join();
var recorded = (await s.RootAsync(TestContext.Current.CancellationToken))
.Count(n => n.NodeId != MqttBrowseSession.TruncatedNodeId
&& n.NodeId != MqttBrowseSession.OversizedNodeId);
recorded.ShouldBeLessThanOrEqualTo(1, $"round {round} overshot the node cap");
}
}
[Fact]
public async Task PlainTopicSegmentWithAControlCharacter_IsRejectedWhole_AndSurfacesAMarker()
{
// Every segment here is well under MaxSegmentChars, so ONLY the control-character rule can
// reject it — the length guard must not be what makes this test pass.
await using var s = new MqttBrowseSession(MqttMode.Plain);
s.ObserveTopicForTest("good/topic", "1");
s.ObserveTopicForTest("bad/oven\nname/temp", "1");
s.OversizedRejected.ShouldBeTrue();
var root = await s.RootAsync(TestContext.Current.CancellationToken);
root.ShouldContain(n => n.NodeId == MqttBrowseSession.OversizedNodeId);
root.ShouldContain(n => n.DisplayName == "good");
root.ShouldNotContain(n => n.DisplayName == "bad"); // no half-built path left behind
}
[Theory]
[InlineData("\n")]
[InlineData("\r")]
[InlineData("\t")]
[InlineData("\u001b")] // ANSI escape
[InlineData("\u0000")]
public async Task PlainTopic_EachControlCharacterForm_IsRejected(string control)
{
await using var s = new MqttBrowseSession(MqttMode.Plain);
s.ObserveTopicForTest($"a/b{control}c/d", "1");
s.OversizedRejected.ShouldBeTrue();
(await s.RootAsync(TestContext.Current.CancellationToken))
.ShouldNotContain(n => n.DisplayName == "a");
}
/// <summary>
/// Falsification control for the rule above: the guard rejects CONTROL characters, not
/// "anything unusual". A topic segment is arbitrary UTF-8 and legitimately carries accents,
/// spaces and punctuation — rejecting those would drop real plant topics from the picker.
/// </summary>
[Fact]
public async Task PlainTopic_PrintableNonAsciiSegments_AreStillAccepted()
{
await using var s = new MqttBrowseSession(MqttMode.Plain);
s.ObserveTopicForTest("usiné/four n°3/temp %", "1");
s.OversizedRejected.ShouldBeFalse();
(await s.RootAsync(TestContext.Current.CancellationToken))
.ShouldHaveSingleItem().DisplayName.ShouldBe("usiné");
}
/// <summary>
/// An EMPTY topic level (<c>a//b</c>, or a trailing <c>/</c>) is legal MQTT and stays accepted —
/// deliberately NOT converged with <c>IsUsableLabel</c>'s non-empty rule. Its terminal leaf is a
/// real, committable address (the commit reads <c>AddressFields[topic]</c>, which carries the
/// node's full path), so rejecting the topic whole would cost the operator a legal address to
/// tidy a blank picker row. The control-character rule is the one that had to converge: a label
/// carrying a newline breaks the row it renders in.
/// </summary>
[Fact]
public async Task PlainTopic_WithAnEmptyLevel_IsStillRecorded()
{
await using var s = new MqttBrowseSession(MqttMode.Plain);
s.ObserveTopicForTest("a//b", "1");
s.OversizedRejected.ShouldBeFalse();
var leaf = (await s.ExpandAsync("a/", TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
leaf.NodeId.ShouldBe("a//b");
var attr = (await s.AttributesAsync("a//b", TestContext.Current.CancellationToken))
.ShouldHaveSingleItem();
attr.AddressFields.ShouldNotBeNull()[MqttTagConfigKeys.Topic].ShouldBe("a//b");
}
// ---------------------------------------------------------------- payload bounds
[Fact]
@@ -554,6 +671,43 @@ public sealed class MqttBrowseSessionTests
.ShouldHaveSingleItem().Name.ShouldBe("Temp");
}
/// <summary>
/// Siblings that share a label must still have a TOTAL order. The level is projected from a
/// <c>ConcurrentDictionary</c> keyed by path — precisely because two Sparkplug siblings CAN share
/// a segment — and its enumeration order is not guaranteed, so ordering by segment alone leaves
/// the tie broken by whatever the dictionary happens to yield. The two rows are visually
/// identical, so a reorder between successive expands is a picker that changes what a click means
/// with nothing on screen to show it.
/// </summary>
[Fact]
public async Task SparkplugTree_SiblingsSharingALabel_AreOrderedDeterministically()
{
// MANY colliding pairs, not one. .NET string hashing is randomised per process, so the tie order
// a segment-only sort falls through to is whatever THIS run's dictionary yields — with a single
// pair it lands the right way round about half the time and the test would pass vacuously. With
// 24 pairs, every tie has to agree by accident for that to happen.
var names = Enumerable.Range(0, 24).Select(i => $"N{i:D2}").ToList();
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
foreach (var name in names)
{
s.ObserveBirthForTest("Plant1", "EdgeA", null, name, SparkplugDataType.Float); // metric
s.ObserveBirthForTest("Plant1", "EdgeA", name, "Value", SparkplugDataType.Float); // device
}
// Segment first (the names, in ordinal order), then path — and Ordinal puts '/' (0x2F) before
// ':' (0x3A), so each device folder precedes the same-named node-level metric.
var expected = names.SelectMany(n => new[] { $"Plant1/EdgeA/{n}", $"Plant1/EdgeA::{n}" }).ToList();
var children = await s.ExpandAsync("Plant1/EdgeA", TestContext.Current.CancellationToken);
children.Select(n => n.NodeId).ShouldBe(expected);
// …and it is stable: expanding the same level again yields the same order.
(await s.ExpandAsync("Plant1/EdgeA", TestContext.Current.CancellationToken))
.Select(n => n.NodeId)
.ShouldBe(expected);
}
[Fact]
public async Task SparkplugTree_EmptyBirth_StillRendersItsDeviceAsAFolder()
{
@@ -423,6 +423,38 @@ public sealed class MqttSubscriptionManagerTests
press.SeedRetained.ShouldBeFalse();
}
/// <summary>
/// An out-of-range driver-level <c>defaultQos</c> must never reach the wire. MQTT defines exactly
/// three QoS levels, and this manager's constructor is the single enforcement point for the
/// driver options it captures — the same place <c>MaxPayloadBytes</c> is bounded. The
/// <c>[Range(0, 2)]</c> attribute on <c>MqttPlainOptions.DefaultQos</c> never ran: nothing in the
/// four MQTT projects calls <c>Validator.TryValidateObject</c>, and the options arrive by
/// <c>JsonSerializer.Deserialize</c>, so a hand-edited or API-supplied driver config put an
/// illegal QoS straight into the SUBSCRIBE. Clamping (rather than falling back to 1) matches what
/// the AdminUI driver form already persists, so the two cannot disagree.
/// </summary>
/// <param name="authored">The authored driver-level default QoS.</param>
/// <param name="expected">The QoS the SUBSCRIBE must carry.</param>
[Theory]
[InlineData(5, 2)]
[InlineData(-1, 0)]
[InlineData(0, 0)]
[InlineData(2, 2)]
public async Task DefaultQos_OutsideTheLegalRange_NeverReachesTheSubscribe(int authored, int expected)
{
var transport = new FakeTransport();
var mgr = new MqttSubscriptionManager(
new MqttDriverOptions { Mode = MqttMode.Plain, Plain = new MqttPlainOptions { DefaultQos = authored } },
"mqtt-1",
transport);
// The tag authors no qos of its own, so the driver-level default is what rides out.
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Raw"}""")]);
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
transport.Calls.ShouldHaveSingleItem().ShouldHaveSingleItem().Qos.ShouldBe(expected);
}
// ---------------------------------------------------------------------------------
// Subscribe / SUBACK contract
// ---------------------------------------------------------------------------------
@@ -177,6 +177,63 @@ public sealed class MqttTagDefinitionFactoryTests
=> MqttTagDefinitionFactory.FromTagConfig(
$$"""{"topic":"a/b","payloadFormat":"Raw","qos":{{qosToken}}}""", RawPath, out _).ShouldBeFalse();
/// <summary>
/// <c>retainSeed</c> is a BEHAVIOURAL key and is held to the same strictness as
/// <c>qos</c> / <c>payloadFormat</c> / <c>dataType</c>. It was the last one read leniently, so
/// <c>"retainSeed":"false"</c> — which a CSV import or a hand-edited blob produces naturally —
/// silently became <see langword="true"/>, the OPPOSITE of what was authored: the tag then seeds
/// its initial value from the broker's retained message when the author asked it not to.
/// </summary>
/// <param name="token">The present-but-invalid <c>retainSeed</c> value.</param>
[Theory]
[InlineData("\"false\"")] // stringly-typed bool — what a CSV import produces
[InlineData("\"true\"")]
[InlineData("0")] // wrong JSON type
[InlineData("1")]
[InlineData("\"yes\"")]
[InlineData("null")] // an explicit null is not "absent"
public void FromTagConfig_MalformedRetainSeed_RejectsStrict(string token)
=> MqttTagDefinitionFactory.FromTagConfig(
$$"""{"topic":"a/b","payloadFormat":"Raw","retainSeed":{{token}}}""", RawPath, out _)
.ShouldBeFalse();
/// <summary>The Sparkplug reader reads the same key and must reach the same verdict.</summary>
/// <param name="token">The present-but-invalid <c>retainSeed</c> value.</param>
[Theory]
[InlineData("\"false\"")]
[InlineData("0")]
[InlineData("null")]
public void FromSparkplugTagConfig_MalformedRetainSeed_RejectsStrict(string token)
=> MqttTagDefinitionFactory.FromSparkplugTagConfig(
$$"""{"groupId":"G","edgeNodeId":"E","metricName":"M","retainSeed":{{token}}}""", RawPath, out _)
.ShouldBeFalse();
/// <summary>
/// Falsification control: the strict read must still take both real booleans and an absent key.
/// A reader that simply rejected every blob carrying the key would pass the theories above.
/// </summary>
[Theory]
[InlineData("true", true)]
[InlineData("false", false)]
public void FromTagConfig_WellFormedRetainSeed_IsStillHonoured(string token, bool expected)
{
MqttTagDefinitionFactory.FromTagConfig(
$$"""{"topic":"a/b","payloadFormat":"Raw","retainSeed":{{token}}}""", RawPath, out var def)
.ShouldBeTrue();
def.RetainSeed.ShouldBe(expected);
}
[Theory]
[InlineData("\"false\"")]
[InlineData("0")]
public void Inspect_MalformedRetainSeed_Warns(string token)
{
// Otherwise clean, so retainSeed is the ONLY thing that can produce a warning.
var warnings = MqttTagDefinitionFactory.Inspect(
$$"""{"topic":"a/b","payloadFormat":"Raw","retainSeed":{{token}}}""");
warnings.ShouldHaveSingleItem().ShouldContain("retainSeed", Case.Sensitive);
}
[Fact]
public void Inspect_CleanBlob_ReturnsEmpty()
=> MqttTagDefinitionFactory.Inspect(
@@ -1,3 +1,4 @@
using System.Globalization;
using System.Text;
using Google.Protobuf;
using Org.Eclipse.Tahu.Protobuf;
@@ -218,6 +219,66 @@ public sealed class MqttWriteEncoderTests
=> MqttWriteEncoder.TryEncode(MqttMode.SparkplugB, Sparkplug("Filler1"), "not-a-number", out _)
.ShouldBe(MqttWriteEncodeError.ValueNotRepresentable);
/// <summary>
/// A Sparkplug <c>DateTime</c> is an UNSIGNED milliseconds-since-epoch, so a pre-1970 instant has
/// no representation at all. The <c>(ulong)</c> cast wrapped the negative <c>long</c> around, so
/// writing <c>1969-12-31</c> published a timestamp roughly 584 million years in the future — a
/// coercion, and the file's own rule is that unrepresentable values are refused, never coerced.
/// </summary>
/// <param name="iso">The authored instant, in UTC.</param>
[Theory]
[InlineData("1969-12-31T23:59:59Z")]
[InlineData("1900-01-01T00:00:00Z")]
[InlineData("0001-01-01T00:00:00Z")]
public void A_pre_epoch_date_time_is_refused_rather_than_wrapped(string iso)
{
var value = DateTime.Parse(iso, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal);
MqttWriteEncoder.TryEncode(
MqttMode.SparkplugB, Sparkplug("Filler1", DriverDataType.DateTime), value, out _)
.ShouldBe(MqttWriteEncodeError.ValueNotRepresentable);
}
/// <summary>
/// Falsification control for the epoch bound: a post-1970 instant still encodes, and encodes to
/// the right number. A guard that refused every <c>DateTime</c> would satisfy the theory above.
/// </summary>
[Fact]
public void A_post_epoch_date_time_still_encodes_to_its_unix_milliseconds()
{
var value = new DateTime(2026, 8, 10, 12, 0, 0, DateTimeKind.Utc);
var payload = Payload.Parser.ParseFrom(EncodeOrThrow(
MqttMode.SparkplugB, Sparkplug("Filler1", DriverDataType.DateTime), value));
payload.Metrics[0].Datatype.ShouldBe((uint)TahuDataType.DateTime);
payload.Metrics[0].LongValue.ShouldBe((ulong)new DateTimeOffset(value).ToUnixTimeMilliseconds());
}
/// <summary>
/// A <see cref="DateTimeKind.Unspecified"/> instant must encode to the SAME wire value on every
/// node. <c>ToUniversalTime()</c> treats an unspecified kind as LOCAL, so the identical authored
/// value encoded differently depending on the node's timezone — which the two nodes of a
/// redundant pair are not required to share. It is read as UTC rather than refused because
/// <c>Convert.ToDateTime("2026-08-10T12:00:00")</c> — an ordinary authored string — produces
/// exactly this kind.
/// </summary>
[Fact]
public void An_unspecified_kind_date_time_encodes_as_utc_not_as_node_local_time()
{
var unspecified = new DateTime(2026, 8, 10, 12, 0, 0, DateTimeKind.Unspecified);
var sameInstantUtc = DateTime.SpecifyKind(unspecified, DateTimeKind.Utc);
var fromUnspecified = Payload.Parser.ParseFrom(EncodeOrThrow(
MqttMode.SparkplugB, Sparkplug("Filler1", DriverDataType.DateTime), unspecified));
var fromUtc = Payload.Parser.ParseFrom(EncodeOrThrow(
MqttMode.SparkplugB, Sparkplug("Filler1", DriverDataType.DateTime), sameInstantUtc));
fromUnspecified.Metrics[0].LongValue.ShouldBe(fromUtc.Metrics[0].LongValue);
fromUnspecified.Metrics[0].LongValue
.ShouldBe((ulong)new DateTimeOffset(sameInstantUtc).ToUnixTimeMilliseconds());
}
[Fact]
public void A_type_with_no_command_encoding_is_refused_rather_than_stringified()
// Reference has no Sparkplug wire representation. Falling back to String would put a value on
@@ -150,6 +150,78 @@ public sealed class SparkplugTopicTests
public void ToTopicString_RoundTrips(string topic)
=> SparkplugTopic.Parse(topic).ToTopicString().ShouldBe(topic);
/// <summary>
/// <see cref="SparkplugTopic.Format"/> must enforce the same per-segment rule
/// <see cref="SparkplugTopic.TryParse"/> does. It validated only null/empty on
/// <c>groupId</c>/<c>edgeNodeId</c> and NOTHING at all on <c>deviceId</c>, so the type was not
/// round-trip-closed: a <c>+</c>/<c>#</c> id produced a topic MQTT-3.3.2-2 forbids in a PUBLISH,
/// and a <c>/</c> in a <c>deviceId</c> silently added a segment. Every production caller is
/// already guarded, so the invariant belongs on the type that owns it rather than on the next
/// caller to arrive.
/// </summary>
/// <param name="groupId">The group id to format with.</param>
/// <param name="edgeNodeId">The edge-node id to format with.</param>
/// <param name="deviceId">The device id to format with, or <see langword="null"/>.</param>
[Theory]
[InlineData("Plant+1", "EdgeA", null)]
[InlineData("Plant#", "EdgeA", null)]
[InlineData("Plant1", "Edge+A", null)]
[InlineData("Plant1", "Edge#A", null)]
[InlineData("Plant1", "EdgeA", "Fill+er")]
[InlineData("Plant1", "EdgeA", "Fill#er")]
[InlineData("Ot/Sim", "EdgeA", null)] // two segments, not one — a DIFFERENT node than named
[InlineData("Plant1", "EdgeA", "Fill/er")]
[InlineData("Plant1", "EdgeA", "")] // would emit a trailing '/', which never re-parses
public void Format_SegmentThatIsNotALegalTopicSegment_Throws(string groupId, string edgeNodeId, string? deviceId)
=> Should.Throw<ArgumentException>(
() => SparkplugTopic.Format(groupId, SparkplugMessageType.DCMD, edgeNodeId, deviceId));
[Theory]
[InlineData("host+1")]
[InlineData("host#")]
public void FormatState_HostIdThatIsNotALegalTopicSegment_Throws(string hostId)
=> Should.Throw<ArgumentException>(() => SparkplugTopic.FormatState(hostId));
/// <summary>
/// Falsification control: the rule is the parser's <c>IsValidSegment</c>, not "reject anything
/// unusual". A guard that rejected every id would satisfy both theories above, and a Sparkplug id
/// is otherwise an opaque identifier — spaces, dots and dashes are all legitimate.
/// </summary>
/// <param name="id">A legal, if unusual, segment.</param>
[Theory]
[InlineData("Plant 1")]
[InlineData("plant.one")]
[InlineData("plant-one_2")]
[InlineData("usiné")]
public void Format_UnusualButLegalSegments_AreStillAccepted(string id)
=> SparkplugTopic.Parse(SparkplugTopic.Format(id, SparkplugMessageType.DCMD, id, id))
.ShouldBe(new SparkplugTopic(SparkplugMessageType.DCMD, id, id, id, null));
/// <summary>
/// The closure property the asymmetry broke: anything <see cref="SparkplugTopic.Format"/> emits
/// must parse back to the instance it was built from. It is asserted over the FORMATTER's own
/// output rather than over a hand-written topic list, so a future formatting change cannot leave
/// the corpus describing a shape the type no longer emits.
/// </summary>
[Fact]
public void Format_Output_AlwaysReParsesToTheSameInstance()
{
SparkplugMessageType[] deviceScoped =
[SparkplugMessageType.DBIRTH, SparkplugMessageType.DDATA, SparkplugMessageType.DDEATH, SparkplugMessageType.DCMD];
foreach (var type in Enum.GetValues<SparkplugMessageType>())
{
if (type == SparkplugMessageType.STATE) continue;
var deviceId = deviceScoped.Contains(type) ? "Filler 1" : null;
var topic = SparkplugTopic.Format("Plant 1", type, "Edge.A", deviceId);
SparkplugTopic.Parse(topic).ShouldBe(new SparkplugTopic(type, "Plant 1", "Edge.A", deviceId, null));
}
SparkplugTopic.Parse(SparkplugTopic.FormatState("otopcua host 1"))
.ShouldBe(new SparkplugTopic(SparkplugMessageType.STATE, null, null, null, "otopcua host 1"));
}
[Fact]
public void ToTopicString_LegacyStateTopic_NormalisesToV3Form()
// Parsed from the legacy no-prefix form, but formatting always emits the v3.0 shape.
@@ -260,6 +260,37 @@ public sealed class TagConfigNonEnumParityTests
TagConfigValidator.Validate(DriverTypeNames.Mqtt, blob).ShouldBeNull();
}
/// <summary>
/// MQTT's <c>retainSeed</c>, held to the same rule as <c>qos</c>: a JSON boolean or absent, and
/// nothing else. The stringly-typed form is what a CSV import produces, and a lenient read turned
/// it into the default <c>true</c> — the INVERSE of the authored intent. The model reads the key
/// leniently into its own nullable field, so this parity holds only because
/// <c>TagConfigParseGate</c> asks the driver's own reader rather than the model's typed fields.
/// </summary>
/// <param name="blob">A blob whose only fault is <c>retainSeed</c>.</param>
[Theory]
[InlineData("""{"topic":"otopcua/fixture/a","retainSeed":"false"}""")]
[InlineData("""{"topic":"otopcua/fixture/a","retainSeed":0}""")]
[InlineData("""{"topic":"otopcua/fixture/a","retainSeed":null}""")]
[InlineData("""{"groupId":"OtOpcUaSim","edgeNodeId":"EdgeA","metricName":"M","retainSeed":"false"}""")]
public void Mqtt_refuses_a_retainSeed_its_reader_refuses(string blob)
{
RuntimeAccepts(DriverTypeNames.Mqtt, blob).ShouldBeFalse();
TagConfigValidator.Validate(DriverTypeNames.Mqtt, blob).ShouldNotBeNullOrEmpty();
}
/// <summary>The paired acceptances: both real booleans, and an absent key.</summary>
/// <param name="blob">A blob whose <c>retainSeed</c> is well-formed or absent.</param>
[Theory]
[InlineData("""{"topic":"otopcua/fixture/a","retainSeed":true}""")]
[InlineData("""{"topic":"otopcua/fixture/a","retainSeed":false}""")]
[InlineData("""{"topic":"otopcua/fixture/a"}""")]
public void Mqtt_accepts_a_retainSeed_its_reader_accepts(string blob)
{
RuntimeAccepts(DriverTypeNames.Mqtt, blob).ShouldBeTrue();
TagConfigValidator.Validate(DriverTypeNames.Mqtt, blob).ShouldBeNull();
}
// =============================================================================================
// 2. The advisory pin — the caution that shapes the whole implementation.
// =============================================================================================