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 |
|
| Review date | 2026-08-10 |
|
||||||
| Commit reviewed | `b990adae` |
|
| Commit reviewed | `b990adae` |
|
||||||
| Status | Reviewed |
|
| Status | Reviewed |
|
||||||
| Open findings | 4 |
|
| Open findings | 0 |
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@ four wildcard shapes, each asserting `PublishCountForTest == 0`.
|
|||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Correctness & logic bugs |
|
| Category | Correctness & logic bugs |
|
||||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttBrowseSession.cs:469` |
|
| 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
|
**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
|
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
|
show" to cover malformed names — the Sparkplug branch already words its marker that way
|
||||||
("oversized or malformed names"), so the two can converge.
|
("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
|
### Driver.Mqtt.Browser-003
|
||||||
|
|
||||||
@@ -118,7 +137,7 @@ show" to cover malformed names — the Sparkplug branch already words its marker
|
|||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Concurrency & thread safety |
|
| Category | Concurrency & thread safety |
|
||||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttBrowseSession.cs:685` |
|
| 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
|
**Description:** `GetOrCreate` reads `_nodeCount` and compares it to `_nodeCap`, then
|
||||||
increments after a successful `GetOrAdd`. The read and the increment are not atomic with
|
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
|
lost-race path (`!ReferenceEquals(child, created)`). Until then, a comment stating the
|
||||||
single-dispatcher assumption the current shape relies on would be enough.
|
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
|
### Driver.Mqtt.Browser-004
|
||||||
|
|
||||||
@@ -147,7 +181,7 @@ single-dispatcher assumption the current shape relies on would be enough.
|
|||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Correctness & logic bugs |
|
| Category | Correctness & logic bugs |
|
||||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttBrowseSession.cs:1079` |
|
| 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
|
**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
|
`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
|
separated. Either is a one-line change; a test observing a device and an edge-node metric of
|
||||||
the same name would pin it.
|
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
|
### Driver.Mqtt.Browser-005
|
||||||
|
|
||||||
@@ -176,7 +223,7 @@ the same name would pin it.
|
|||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Performance & resource management |
|
| Category | Performance & resource management |
|
||||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttBrowseSession.cs:59`, `:81` |
|
| 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
|
**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`
|
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`,
|
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.
|
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.
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
| Review date | 2026-08-10 |
|
| Review date | 2026-08-10 |
|
||||||
| Commit reviewed | `b990adae` |
|
| Commit reviewed | `b990adae` |
|
||||||
| Status | Reviewed |
|
| Status | Reviewed |
|
||||||
| Open findings | 4 |
|
| Open findings | 0 |
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
@@ -162,7 +162,7 @@ deliberately excludes `metricName`); pinned by
|
|||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Documentation & comments |
|
| Category | Documentation & comments |
|
||||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagDefinitionFactory.cs:124`, `:234`, `:476` |
|
| 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.
|
**Description:** `retainSeed` is the one behavioural key still read leniently.
|
||||||
`ReadBoolOrDefault` returns the default for any non-boolean JSON value, so
|
`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`.
|
default; or (c) at minimum document the deliberate exception on `ReadBoolOrDefault`.
|
||||||
Option (b) is lowest-risk — it changes no verdict.
|
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
|
### Driver.Mqtt.Contracts-005
|
||||||
|
|
||||||
@@ -195,7 +224,7 @@ Option (b) is lowest-risk — it changes no verdict.
|
|||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Testing coverage |
|
| Category | Testing coverage |
|
||||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttDriverOptions.cs:25`, `:71`, `:75`, `:79`, `:83`, `:99`, `:229` |
|
| 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
|
**Description:** `MqttDriverOptions` / `MqttPlainOptions` carry seven
|
||||||
`System.ComponentModel.DataAnnotations` `[Range]` attributes and **nothing ever runs them**.
|
`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`)
|
that it is descriptive. The enforcing consumer (`MqttSubscriptionManager.BuildFilters`)
|
||||||
lives in `Driver.Mqtt`, outside this module.
|
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
|
### Driver.Mqtt.Contracts-006
|
||||||
|
|
||||||
@@ -230,7 +287,7 @@ lives in `Driver.Mqtt`, outside this module.
|
|||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Correctness & logic bugs |
|
| Category | Correctness & logic bugs |
|
||||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttWriteEncoder.cs:281` |
|
| 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,
|
**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
|
`(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).
|
`Unspecified` input (or refuse it, which is more in keeping with the file's own posture).
|
||||||
Add a test for each.
|
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
|
### Driver.Mqtt.Contracts-007
|
||||||
|
|
||||||
@@ -259,7 +336,7 @@ Add a test for each.
|
|||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Error handling & resilience |
|
| Category | Error handling & resilience |
|
||||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/Sparkplug/SparkplugTopic.cs:202`, `:232` |
|
| 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
|
**Description:** `SparkplugTopic` validates asymmetrically. `TryParse` enforces
|
||||||
`IsValidSegment` (non-empty, no `+`/`#`) on every segment it reads, but `Format` and
|
`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
|
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.
|
(`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.
|
||||||
|
|||||||
Reference in New Issue
Block a user