docs(code-review): close all 8 open Mqtt.Browser + Mqtt.Contracts findings

Browser-002/-003/-004 fixed and -005 resolved as a documented constants change
(eddf5f57); Contracts-004/-005/-006/-007 fixed (93f70504). Both module headers
drop to 0 open findings. Each resolution records the disposition, the RED
evidence, and the deviations from the recommendation where one was taken.
This commit is contained in:
Joseph Doherty
2026-08-10 08:22:47 -04:00
parent 93f70504bd
commit 2dc2075d05
2 changed files with 185 additions and 18 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.