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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user