Compare commits
32 Commits
d06e92c631
...
94b8633e68
| Author | SHA1 | Date | |
|---|---|---|---|
| 94b8633e68 | |||
| fd7d06bb12 | |||
| d68009106b | |||
| 147b624437 | |||
| c58e833aee | |||
| 978eeda753 | |||
| 9ccc0d5678 | |||
| fefb240350 | |||
| 416c471809 | |||
| 74b852716c | |||
| 33f7472002 | |||
| b4a03b4baa | |||
| 95cdb6c578 | |||
| a836e0d983 | |||
| 2b4b3fcbbf | |||
| 7eba588305 | |||
| ce5c6d093c | |||
| 256d943f1e | |||
| 368d8e9581 | |||
| 03eadae677 | |||
| 9ca6042f57 | |||
| 69ad376f0d | |||
| ac3a2c6c27 | |||
| 19feb1b289 | |||
| a86a09767c | |||
| 55b12dc217 | |||
| 3118213772 | |||
| 6c63bf8fab | |||
| 6eace4bbba | |||
| 959024de60 | |||
| b990adae55 | |||
| 0319cf5de2 |
@@ -0,0 +1,55 @@
|
||||
# Code Review — Driver.Calculation.Contracts
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Module | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Contracts` |
|
||||
| Reviewer | Claude (automated review agent) |
|
||||
| Review date | 2026-08-10 |
|
||||
| Commit reviewed | `b990adae` |
|
||||
| Status | Reviewed |
|
||||
| Open findings | 1 |
|
||||
|
||||
## Checklist coverage
|
||||
|
||||
| # | Category | Result |
|
||||
|---|---|---|
|
||||
| 1 | Correctness & logic bugs | No issues found. `TryReadTagConfig`'s field-by-field reading (scriptId/scriptSource/changeTriggered/timerIntervalMs) matches its own documented lenient-defaulting rules exactly, and `Inspect`'s warnings track the same rules one-for-one — verified by cross-reading both against `TagConfigNonEnumParityTests.cs` in AdminUI.Tests. |
|
||||
| 2 | OtOpcUa conventions | No issues found. This is a pure JSON-reading leaf with no transport, no gateway, and no Galaxy-specific surface — the relevant conventions here are the deploy-time TagConfig-gate architecture (G-7: one authority shared by the runtime read and the Inspect gate), which this module satisfies by construction (`CalculationTagDefinition.TryFromTagConfig`, Driver.Calculation, calls `CalculationTagDefinitionFactory.TryReadTagConfig`, this module, rather than re-implementing the blob read). |
|
||||
| 3 | Concurrency & thread safety | No issues found. Purely functional (`JsonDocument.Parse` + local reads), no shared mutable state. |
|
||||
| 4 | Error handling & resilience | No issues found. Both `TryReadTagConfig` and `Inspect` are documented never to throw and both live up to it: every `JsonDocument.Parse` call is wrapped in `try/catch (JsonException)`, and a malformed blob is a `false`/warning result rather than an exception escaping to the deploy pipeline or the runtime driver. |
|
||||
| 5 | Security | No issues found. No secrets, no I/O beyond parsing an in-memory string; nothing here can be used for injection (script *execution* is a Roslyn-sandboxed concern owned by `Core.Scripting`, not this module). |
|
||||
| 6 | Performance & resource management | 1 finding (Driver.Calculation.Contracts-001), Open, Low — a deliberate double-parse in `Inspect`, at deploy-time only (not a runtime hot path). |
|
||||
| 7 | Design-document adherence | No issues found. The class-level remarks explicitly state the "one authority" split (this module owns what the BLOB says; `Driver.Calculation` owns identity + dependency-ref extraction) and the "Inspect is never stricter than TryReadTagConfig" invariant (pinned by a falsification-control test in AdminUI.Tests, per the project memory on this exact pattern) — both hold under inspection of the actual code. |
|
||||
| 8 | Code organization & conventions | No issues found. Single-file, single-responsibility leaf; the split rationale (why `NamespaceMap`-style SDK-typed helpers don't belong here, why the dependency-ref extraction stays in `Driver.Calculation`) is documented inline and matches the actual dependency graph. |
|
||||
| 9 | Testing coverage | No issues found. There is no dedicated `Driver.Calculation.Contracts.Tests` project, but this matches the fleet-wide convention — no `*.Contracts` driver library anywhere in the repo has one (`ls tests/Drivers \| grep -i Contracts` is empty). `TryReadTagConfig` is exercised indirectly via `CalculationTagDefinitionTests` in `Driver.Calculation.Tests`, and `Inspect` is directly tested in `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagConfigNonEnumParityTests.cs` (`A_Calculation_blob_that_draws_an_advisory_but_parses_clean_is_accepted_at_authoring`), which specifically pins the "Inspect never stricter than the runtime read" invariant with a falsification control. |
|
||||
| 10 | Documentation & comments | No issues found. The XML docs are accurate and unusually explicit about WHY the design is shaped the way it is (the authority split, the deliberately-weaker-than-AdminUI Inspect semantics called out in the review brief). |
|
||||
|
||||
## Findings
|
||||
|
||||
### Driver.Calculation.Contracts-001
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | Low |
|
||||
| Category | Performance & resource management |
|
||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Contracts/CalculationTagDefinitionFactory.cs:107-160` (`Inspect`) |
|
||||
| Status | Open |
|
||||
|
||||
**Description:** `Inspect` parses `reference` with `JsonDocument.Parse` once directly (to check the root
|
||||
kind and read `changeTriggered`/`timerIntervalMs` for the "present-but-discarded" warnings), and then —
|
||||
when the object parsed cleanly — calls `TryReadTagConfig(reference, out _)` (line 133), which parses the
|
||||
SAME string a second time from scratch to decide whether a usable `scriptId` is present. This is a
|
||||
deliberate consequence of the module's own "one authority" design (`Inspect` must consult
|
||||
`TryReadTagConfig`, not reimplement its scriptId rule), so it is not a bug, but it is a real double-parse
|
||||
of every calc tag's `TagConfig` blob on every deploy-time gate pass.
|
||||
|
||||
**Recommendation:** If this ever needs to scale beyond a deploy-time gate (e.g. if `Inspect` starts being
|
||||
called from a hot path, which it is not today), consider giving `TryReadTagConfig` an internal overload
|
||||
that accepts an already-parsed `JsonElement` so `Inspect` can pass its own `root` through without a
|
||||
second `JsonDocument.Parse`. Not pursued in this review: `TryReadTagConfig`'s public string-based
|
||||
signature is consumed by other modules (`Driver.Calculation`'s `CalculationTagDefinition.TryFromTagConfig`,
|
||||
and `AdminUI`'s `CalculationTagConfigModel.cs`), so widening it safely is a small cross-module change, not
|
||||
a same-file one-liner, and the current cost is one extra JSON parse per calc tag at deploy time — not a
|
||||
runtime cost.
|
||||
|
||||
**Resolution:** _(Open — Low severity, record-only per review policy; deploy-time only, not a hot path.)_
|
||||
@@ -0,0 +1,225 @@
|
||||
# Code Review — Driver.Calculation
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Module | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation` |
|
||||
| Reviewer | Claude (automated review agent) |
|
||||
| Review date | 2026-08-10 |
|
||||
| Commit reviewed | `b990adae` |
|
||||
| Status | Reviewed |
|
||||
| Open findings | 1 |
|
||||
|
||||
## Checklist coverage
|
||||
|
||||
| # | Category | Result |
|
||||
|---|---|---|
|
||||
| 1 | Correctness & logic bugs | 3 findings (Driver.Calculation-001, -002, -003), all Resolved |
|
||||
| 2 | OtOpcUa conventions | No issues found. Galaxy/MXAccess-specific conventions do not apply to this pseudo-driver (no transport, no gateway). It correctly implements the capability-interface seams (IDriver, IDependencyConsumer, ISubscribable, IReadable, ISkippedTagReporter), .NET 10/AnyCPU, and authorization stays out of driver-level code. |
|
||||
| 3 | Concurrency & thread safety | 1 finding (Driver.Calculation-005), Open (cross-module) |
|
||||
| 4 | Error handling & resilience | Covered by Driver.Calculation-002 (script-evaluation faults never throw out of the driver; SkippedTagLedger tolerates a single bad tag without failing the whole rebuild). No further issues found. |
|
||||
| 5 | Security | No issues found. No secrets, no I/O, no SQL; the sandboxed Roslyn evaluator (shared Core.Scripting sandbox) is the security boundary and lives outside this module. |
|
||||
| 6 | Performance & resource management | Covered by Driver.Calculation-005 (blocking evaluation under a shared lock). CalculationTimerScheduler correctly bounds outstanding work to one tick per interval group (skip-if-in-flight) and CalculationEvaluator.ClearCompiledScripts() is invoked on every ReinitializeAsync, so compiled-script AssemblyLoadContexts do not accrete across script edits. No further issues found. |
|
||||
| 7 | Design-document adherence | No issues found. The class-level XML docs cite the "mini-design" sections directly and the implementation matches them (change-gate, once-per-transition Bad, force-published recovery, GetHealth() per Gitea #517, ISkippedTagReporter per Gitea #541). |
|
||||
| 8 | Code organization & conventions | 1 finding (Driver.Calculation-003, also an organization smell: three near-duplicate JsonSerializerOptions definitions across the module). Otherwise clean: one construction routine (RebuildTagTable) shared by the ctor and ReinitializeAsync so the two paths cannot drift. |
|
||||
| 9 | Testing coverage | 1 finding (Driver.Calculation-004), Resolved (tests added). CalculationTimerScheduler.SkippedTickCount remains untested — see note under -004. |
|
||||
| 10 | Documentation & comments | No issues found. XML docs are extensive, accurate, and were kept in sync with the fixes below. |
|
||||
|
||||
## Findings
|
||||
|
||||
### Driver.Calculation-001
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | Medium |
|
||||
| Category | Correctness & logic bugs |
|
||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationDriver.cs:103-106` (pre-fix), `CalculationEvaluator.cs:47,56-59` (pre-fix) |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `CalculationDriver`'s constructor built the evaluator's logger as
|
||||
`_logger is ILogger<CalculationEvaluator> el ? el : NullLogger<CalculationEvaluator>.Instance`, where
|
||||
`_logger` is typed `ILogger<CalculationDriver>`. `ILogger<CalculationDriver>` and
|
||||
`ILogger<CalculationEvaluator>` are unrelated closed generic interfaces (no inheritance relationship
|
||||
between `CalculationDriver` and `CalculationEvaluator`), so the concrete logger instance the host injects
|
||||
(a `Logger<CalculationDriver>` from `ILoggerFactory.CreateLogger<CalculationDriver>()`, or
|
||||
`NullLogger<CalculationDriver>.Instance`) never actually implements `ILogger<CalculationEvaluator>`. The
|
||||
`is` check was therefore **always false**, and every `CalculationEvaluator` silently ran with
|
||||
`NullLogger<CalculationEvaluator>.Instance` regardless of what logger the driver itself was constructed
|
||||
with — swallowing the evaluator's host-side diagnostics (Roslyn compile failures, sandbox violations,
|
||||
unexpected execution throws, the single-tag `SetVirtualTag`-drop Debug line) with no way to route them to
|
||||
the real host log sink. (The actual script-failure *reason* still reached the operator via the
|
||||
`script-logs` DPS topic through `CalculationDriver.PublishScriptLog`, so this was a silent loss of
|
||||
redundant host-side diagnostics rather than a loss of the primary operator signal — hence Medium, not
|
||||
High.)
|
||||
|
||||
**Recommendation:** Have `CalculationEvaluator` accept the non-generic `ILogger` (every `ILogger<T>`
|
||||
already implements it) instead of the closed generic `ILogger<CalculationEvaluator>`, and pass the
|
||||
driver's own `_logger` straight through.
|
||||
|
||||
**Resolution:** Fixed 2026-08-10 (branch `review/calculation-session`). Changed
|
||||
`CalculationEvaluator`'s constructor parameter/field from `ILogger<CalculationEvaluator>` to `ILogger`,
|
||||
and `CalculationDriver`'s constructor now does `_evaluator = new CalculationEvaluator(_logger, _scriptRoot,
|
||||
_options.RunTimeout);` with no type gymnastics. Regression test
|
||||
`CalculationDriverTests.Evaluator_compile_diagnostics_flow_through_to_the_drivers_own_logger` (a
|
||||
capturing `ILogger<CalculationDriver>` passed into the driver, a deliberately broken timer-triggered
|
||||
script, asserting the capturing logger receives the "Roslyn compile failed" Warning) fails against the
|
||||
pre-fix code and passes after.
|
||||
|
||||
---
|
||||
|
||||
### Driver.Calculation-002
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | High |
|
||||
| Category | Correctness & logic bugs |
|
||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationDriver.cs:374-410` (pre-fix `EvaluateTag`) |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `EvaluateTag` computed the change-gate flag (`allArrived`) but only used it to decide
|
||||
whether to *publish* the result — it called `_evaluator.Evaluate(...)` **before** checking `allArrived`,
|
||||
against a dependency snapshot that only contains the deps that HAVE arrived. `VirtualTagContext.GetTag`
|
||||
(shared with VirtualTag scripts) returns a Bad snapshot with `.Value == null` for a key not present in
|
||||
the read cache. The idiomatic calc-tag script shape used throughout this module's own tests and comments —
|
||||
`return (double)ctx.GetTag("src/a").Value + (double)ctx.GetTag("src/b").Value;` — casts that `null` to a
|
||||
non-nullable value type, which throws `NullReferenceException` at the unboxing conversion. That exception
|
||||
was caught by `CalculationEvaluator.Evaluate`'s generic `catch (Exception ex)` and reported as a
|
||||
`Failure("script threw: Object reference not set to an instance of an object.")`, which
|
||||
`OnEvaluationFailed` then unconditionally logged to the `script-logs` topic as a Warning — **on every
|
||||
partial-dependency arrival**, for any multi-dependency change-triggered calc tag whose declared deps
|
||||
don't all arrive on the same tick. This is not a corner case: it fires on ordinary warm-up (every deploy
|
||||
and every driver restart) for the standard multi-input calc-tag shape, producing a false-positive
|
||||
"script threw" warning that looks exactly like a real script bug even though nothing is wrong, plus a
|
||||
wasted Roslyn/compiled-cache invocation per partial arrival.
|
||||
|
||||
**Recommendation:** Move the `allArrived` check before the call to `_evaluator.Evaluate` so the script
|
||||
never runs against a snapshot it wasn't declared against, matching the class's own documented intent
|
||||
("publish nothing until every declared dep has arrived at least once").
|
||||
|
||||
**Resolution:** Fixed 2026-08-10 (branch `review/calculation-session`). `EvaluateTag` now returns before
|
||||
building the dependency snapshot / calling the evaluator when `!def.DependencyRefs.All(_arrivedDeps.Contains)`.
|
||||
`OnEvaluationFailed`'s now-always-true `allArrived` parameter was removed (the caller only reaches it once
|
||||
every dep has arrived). Regression test
|
||||
`CalculationDriverTests.No_evaluation_or_script_log_while_a_declared_dep_is_still_missing` reproduces the
|
||||
exact scenario from `No_publish_until_every_declared_dep_has_arrived` but also asserts `logs.ShouldBeEmpty()`;
|
||||
it fails with the captured `"script threw: Object reference not set..."` entry against the pre-fix code
|
||||
and passes after. The pre-existing `No_publish_until_every_declared_dep_has_arrived` and
|
||||
`Failure_publishes_Bad_once_per_transition_emits_script_log_and_recovers` tests are unaffected (neither
|
||||
exercises a multi-dep tag mid-warm-up while asserting on `logs`).
|
||||
|
||||
---
|
||||
|
||||
### Driver.Calculation-003
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | Medium |
|
||||
| Category | Correctness & logic bugs / Code organization & conventions |
|
||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationDriverProbe.cs:16-21` (pre-fix) |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** The module defines its config-parsing `JsonSerializerOptions` in three places —
|
||||
`CalculationDriverFactoryExtensions.JsonOptions` (spawn), `CalculationDriver.ReinitJsonOptions` (in-place
|
||||
ApplyDelta), and `CalculationDriverProbe.Opts` (Test-Connect) — and the first two both set
|
||||
`ReadCommentHandling = JsonCommentHandling.Skip` and `AllowTrailingCommas = true` while the probe's did
|
||||
not. A config blob the real spawn/reinit path tolerates (a trailing comma or a `//` comment, plausible for
|
||||
a hand-edited config used during troubleshooting) could therefore fail Test-Connect with "Config JSON is
|
||||
invalid" even though deploying the identical JSON would succeed — the probe was **stricter** than the path
|
||||
it exists to stand in for, the opposite of its documented contract ("Never throws; returns Ok with
|
||||
negligible latency" / parse-only parity with the real path).
|
||||
|
||||
**Recommendation:** Align `CalculationDriverProbe.Opts` with the other two option sets (or extract a
|
||||
single shared `JsonSerializerOptions` constant the three call sites reuse, to remove the duplication risk
|
||||
entirely for future changes).
|
||||
|
||||
**Resolution:** Fixed 2026-08-10 (branch `review/calculation-session`). Added
|
||||
`ReadCommentHandling = JsonCommentHandling.Skip` and `AllowTrailingCommas = true` to `Opts`, with a
|
||||
comment naming the two option sets it must stay in parity with. New test file
|
||||
`CalculationDriverProbeTests.cs` (previously nonexistent — see Driver.Calculation-004) pins this with
|
||||
`Tolerates_trailing_commas_like_the_real_spawn_and_reinit_paths` and
|
||||
`Tolerates_comments_like_the_real_spawn_and_reinit_paths`, both of which fail against the pre-fix `Opts`
|
||||
and pass after.
|
||||
|
||||
---
|
||||
|
||||
### Driver.Calculation-004
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | Medium |
|
||||
| Category | Testing coverage |
|
||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationDriverProbe.cs`, `CalculationDriver.cs` (`ISkippedTagReporter`/`GetSkippedTags()`), `CalculationTimerScheduler.cs` (`SkippedTickCount`) |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** Two behaviours in this module had zero test coverage before this review:
|
||||
|
||||
1. `CalculationDriverProbe` had no test file at all — the Test-Connect surface for this driver type was
|
||||
completely unverified (and, per Driver.Calculation-003, was in fact wrong).
|
||||
2. `CalculationDriver`'s implementation of `ISkippedTagReporter`/`GetSkippedTags()` (the Gitea #541
|
||||
authored-vs-mapped-tag visibility feature — the exact class of bug that let a driver silently ignore
|
||||
20 of 22 tags while still reporting Healthy) had no test verifying a raw tag with no `scriptId` is
|
||||
actually reported skipped, that `AuthoredCount`/`SkippedCount`/`Sample` are correct, or that the report
|
||||
is rebuilt (not stale) after `ReinitializeAsync`.
|
||||
|
||||
`CalculationTimerScheduler.SkippedTickCount` (the in-flight-tick-skip counter) remains untested — left
|
||||
open rather than force-fitted, since reliably forcing a same-group timer overlap without a sleep-based
|
||||
flaky test would need either an injectable clock/scheduler or an artificially slow evaluator hook, neither
|
||||
of which exists yet in this module.
|
||||
|
||||
**Recommendation:** Add a dedicated `CalculationDriverProbeTests.cs`; add `GetSkippedTags()` coverage to
|
||||
`CalculationDriverTests.cs`. For `SkippedTickCount`, a follow-up could add a test-only evaluator delay hook
|
||||
if the counter ever needs to be pinned by a test rather than only exercised implicitly.
|
||||
|
||||
**Resolution:** Fixed 2026-08-10 (branch `review/calculation-session`). Added
|
||||
`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests/CalculationDriverProbeTests.cs` (6 tests,
|
||||
including the two parity regressions from -003). Added to `CalculationDriverTests.cs`:
|
||||
`GetSkippedTags_reports_a_raw_tag_with_no_scriptId_alongside_the_authored_total`,
|
||||
`GetSkippedTags_has_no_skips_when_every_authored_tag_maps`, and
|
||||
`GetSkippedTags_updates_after_reinitialize`. `SkippedTickCount` is intentionally left without a dedicated
|
||||
test — noted above, not silently dropped.
|
||||
|
||||
---
|
||||
|
||||
### Driver.Calculation-005
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | Medium |
|
||||
| Category | Concurrency & thread safety |
|
||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationDriver.cs:300-314` (`OnDependencyValue`), `:374-410` (`EvaluateTag`); cross-module: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyConsumerMuxAdapter.cs` |
|
||||
| Status | Open |
|
||||
|
||||
**Description:** `OnDependencyValue` evaluates every change-triggered dependent tag **inline, under
|
||||
`_lock`**, and `EvaluateTag`'s call into `CalculationEvaluator.Evaluate` ultimately runs
|
||||
`TimedScriptEvaluator<...>.RunAsync(context).GetAwaiter().GetResult()` — a synchronous blocking wait bounded
|
||||
by `RunTimeout` (default 2 s, configurable via `RunTimeoutMs`, with no enforced ceiling). `OnDependencyValue`
|
||||
is invoked directly and synchronously from `DependencyConsumerMuxAdapter.OnDependencyValueChanged`
|
||||
(`src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyConsumerMuxAdapter.cs:66-70`), an Akka
|
||||
`ReceiveActor`'s message handler — i.e. on the actor's dispatcher thread. A calc script that runs close to
|
||||
its timeout budget therefore blocks that thread (and, transitively, that specific driver's dependency-value
|
||||
mailbox) for up to `RunTimeout` on every dependency arrival, and — because `TimedScriptEvaluator` offloads
|
||||
the actual run via `Task.Run` while the caller blocks synchronously waiting on it — occupies a pool thread
|
||||
for the duration rather than yielding it, which is a known thread-pool-starvation risk under Akka's
|
||||
fork-join dispatcher if enough calc drivers are evaluating slow scripts concurrently. The in-line comment
|
||||
("the compile cache makes this a bounded method invocation — the same 'fast enough to run inline' contract
|
||||
as the VT evaluator") is not actually enforced by anything faster than `RunTimeout` itself — the existence
|
||||
of the timeout mechanism is evidence a script CAN run slow, not evidence it never will.
|
||||
|
||||
This exact pattern (`_evaluator.Evaluate(...)` called unconditionally, inline, from an Akka actor's
|
||||
synchronous message handler, with the identical `GetAwaiter().GetResult()` blocking shape) is also present
|
||||
in `VirtualTagActor.OnDependencyChanged` (`src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs:179-199`),
|
||||
which `CalculationDriver`'s own docs explicitly say this module mirrors — so this is a systemic,
|
||||
pre-existing trade-off in the scripting engine, not something introduced by the Calculation driver alone.
|
||||
Recorded here because the blocking call itself lives in this module's code (`CalculationDriver.cs`), even
|
||||
though a full fix spans `Core.Abstractions` (the synchronous `IDependencyConsumer.OnDependencyValue`
|
||||
contract) and `Runtime` (the adapter actor) — both out of this review's module scope.
|
||||
|
||||
**Recommendation:** Not a fix for this review (cross-module: the synchronous `IDependencyConsumer` contract
|
||||
lives in `Core.Abstractions`, and the actor calling it lives in `Runtime`). If this risk is judged worth
|
||||
closing, options include: (a) giving `IDependencyConsumer.OnDependencyValue` an async signature so the actor
|
||||
can `PipeTo` the result instead of blocking its own thread; (b) having `CalculationDriver` dispatch evaluation
|
||||
onto a dedicated bounded scheduler (e.g. `Task.Run` from `OnDependencyValue` itself, publishing the result
|
||||
asynchronously) so the actor thread returns immediately; or (c) tightening `RunTimeout`'s effective ceiling
|
||||
independent of the `RunTimeoutMs` config knob. Any of these should be scoped and reviewed together with the
|
||||
equivalent `VirtualTagActor` behaviour so the two mirrors do not diverge.
|
||||
|
||||
**Resolution:** _(Open — cross-module, not fixed as part of this review.)_
|
||||
@@ -0,0 +1,239 @@
|
||||
# Code Review — Driver.Historian.Gateway
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Module | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway` |
|
||||
| Reviewer | Claude Code |
|
||||
| Review date | 2026-08-10 |
|
||||
| Commit reviewed | `b990adae` |
|
||||
| Status | Reviewed |
|
||||
| Open findings | 0 |
|
||||
|
||||
## Scope
|
||||
|
||||
The sole historian backend: a thin gRPC seam (`IHistorianGatewayClient` /
|
||||
`HistorianGatewayClientAdapter`) over the `ZB.MOM.WW.HistorianGateway.Client` package, plus
|
||||
`GatewayHistorianDataSource` (HistoryRead), `GatewayAlarmHistorianWriter` (alarm `SendEvent`),
|
||||
`GatewayTagProvisioner` (`EnsureTags` upsert), the `Mapping/` pure mappers, and the
|
||||
`Recorder/` continuous-historization write path (`FasterLogHistorizationOutbox`,
|
||||
`GatewayHistorianValueWriter`, `HistorizationOutboxEntrySerializer`). 17 source files, ~1,850 LOC
|
||||
(excluding generated/obj); test suite `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests`
|
||||
(130 tests: 124 offline unit + 6 env-gated `Category=LiveIntegration`, skip cleanly offline).
|
||||
|
||||
Read against `CLAUDE.md`'s "Historian / HistoryRead" section in full before reviewing — several
|
||||
things that look like defects on first read are deliberate, documented decisions (Boolean→Int2,
|
||||
the dead `Quality` field on `WriteLiveValues`, the per-path-own-channel design, the two `EnsureTags`
|
||||
gotchas, the #491/#537/#538 live-gate history). None of those were re-litigated here.
|
||||
|
||||
## Checklist coverage
|
||||
|
||||
| # | Category | Result |
|
||||
|---|---|---|
|
||||
| 1 | Correctness & logic bugs | 1 finding (Driver.Historian.Gateway-001, High) — Resolved |
|
||||
| 2 | OtOpcUa conventions | No issues found |
|
||||
| 3 | Concurrency & thread safety | No issues found |
|
||||
| 4 | Error handling & resilience | No issues found |
|
||||
| 5 | Security | No issues found |
|
||||
| 6 | Performance & resource management | 1 finding (Driver.Historian.Gateway-002, Low) — Resolved |
|
||||
| 7 | Design-document adherence | No issues found |
|
||||
| 8 | Code organization & conventions | No issues found |
|
||||
| 9 | Testing coverage | 1 finding (Driver.Historian.Gateway-003, Medium) — Resolved |
|
||||
| 10 | Documentation & comments | No issues found |
|
||||
|
||||
### Category notes
|
||||
|
||||
**1. Correctness & logic bugs.** See Driver.Historian.Gateway-001 (Severity write/read mismatch).
|
||||
Everything else checked out: `SampleMapper`/`GatewayQualityMapper` quality-byte bucketing is exact
|
||||
and tested per-byte; `AggregateModeMapper`/`HistorianTypeMapper` are exhaustive-matrix-guarded
|
||||
(`Every_..._member_is_classified` tests fail on a future unmapped enum member rather than silently
|
||||
defaulting); `GatewayReadEventsTests` confirms the truncation/continuation-point boundary
|
||||
(`collected.Count == maxEvents` checked *before* `Add`, so an exact-cap read has no continuation but
|
||||
one more matching event sets it); `AlignAtTimeSnapshots` correctly keeps the first of a duplicate
|
||||
timestamp and Bad-fills gaps at the *requested* time, not the nearest returned one.
|
||||
|
||||
**2. OtOpcUa conventions.** Driver-level code contains no OPC UA SDK reference (`uint` status codes
|
||||
via the named `HistorianStatusCodes`, not the SDK's `Opc.Ua.StatusCodes` — matches the repo's stated
|
||||
pattern of keeping the driver layer SDK-free so `StatusCodeParityTests` elsewhere can reflect-guard
|
||||
it). No authorization decisions are made in this driver — HistoryRead access-level gating happens at
|
||||
materialization in the server layer per CLAUDE.md, and this module is a pure translation seam.
|
||||
Serilog-style `ILogger<T>` used throughout via constructor injection.
|
||||
|
||||
**3. Concurrency & thread safety.** `GatewayHistorianDataSource`'s health counters
|
||||
(`_totalQueries`/`_totalSuccesses`/`_totalFailures`/etc.) are all mutated and read under one
|
||||
`_healthLock`, preserving the `TotalQueries == TotalSuccesses + TotalFailures` invariant at every
|
||||
observed snapshot (per its own XML remarks and `GatewayHealthSnapshotTests`).
|
||||
`FasterLogHistorizationOutbox` documents an explicit single-writer assumption (the recorder actor
|
||||
processes messages sequentially) and its `_state` lock protects only the in-memory FIFO index, never
|
||||
held across an `await` — correct, since FasterLog itself tolerates concurrent enqueue/scan. No shared
|
||||
mutable state was found that lacked a lock or a documented ownership model.
|
||||
|
||||
**4. Error handling & resilience.** `GatewayAlarmHistorianWriter` and `GatewayHistorianValueWriter`
|
||||
both honor their "never throws" contracts (verified by dedicated exception-classification tests
|
||||
covering the typed client hierarchy, a defensive raw `RpcException` path, and an unclassifiable
|
||||
exception default). Cancellation is handled as a first-class, non-poisoning case in both the alarm
|
||||
writer (mid-drain cancellation yields `RetryPlease`, never `PermanentFail`) and the tag provisioner
|
||||
(a shutdown `OperationCanceledException` is logged quietly and counted `Failed`, never thrown). The
|
||||
fire-and-forget `EnsureTagsAsync` hook is genuinely non-blocking end to end — no `await` in its
|
||||
caller's synchronous path can escape as an exception (verified by tracing `AddressSpaceApplier`'s
|
||||
fire-and-forget dispatch, outside this module's scope but confirmed non-blocking here).
|
||||
|
||||
**5. Security.** No API key, tag value, hostname, or credential is ever passed to a logger call
|
||||
anywhere in the module (audited every `_logger.Log*` call site) — failures are logged by exception
|
||||
*type name* only. `HistorianGatewayClientAdapter.Create` echoes the `Endpoint` in its
|
||||
`InvalidOperationException` (deliberately, documented as non-secret) but never the `ApiKey`. TLS /
|
||||
certificate-validation options (`UseTls`, `AllowUntrustedServerCertificate`, `CaCertificatePath`) map
|
||||
onto the package client with the documented inverted `RequireCertificateValidation` semantics and are
|
||||
correctly *not* forwarded on a plaintext h2c connection (pinned by
|
||||
`Adapter_ignores_tls_only_settings_on_a_plaintext_endpoint`). No SQL or other injectable surface in
|
||||
this module.
|
||||
|
||||
**6. Performance & resource management.** See Driver.Historian.Gateway-002 (missing dispose on
|
||||
`GatewayTagProvisioner`). Everything else: `GatewayHistorianDataSource` and
|
||||
`GatewayAlarmHistorianWriter` both correctly dispose their owned `IHistorianGatewayClient` (verified
|
||||
by dedicated tests); the three `GatewayHistorian.Create*` factories deliberately open independent
|
||||
gRPC channels per path rather than sharing one, a documented and reasonable trade (each channel's
|
||||
lifetime stays simple; the gateway pools/amortizes historian sessions server-side). Channel
|
||||
construction is lazy (no network I/O at construction, confirmed by the adapter's own tests) so a
|
||||
misconfigured/disabled path never dials. Per-call deadlines exist via
|
||||
`ServerHistorianOptions.CallTimeout` → `HistorianGatewayClientOptions.DefaultCallTimeout`, and every
|
||||
seam method threads a `CancellationToken` through to the package client — no unbounded call was found.
|
||||
|
||||
**7. Design-document adherence.** Matches `CLAUDE.md`'s "Historian / HistoryRead" section point for
|
||||
point: gateway is the sole backend; Boolean→Int2 is implemented exactly as documented (with an
|
||||
explicit-presence pin test guarding the proto3-optional contract); the continuous-historization
|
||||
outbox/writer/serializer shape matches the FasterLog-outbox design; the `Quality` field is
|
||||
deliberately populated despite being a documented-dead input on the SQL write path, with the
|
||||
reasoning captured in the class's own remarks (proto3 default-0 would be a worse lie than the
|
||||
correct-but-ignored 192). No stale documentation was found in this module.
|
||||
|
||||
**8. Code organization & conventions.** Clean separation: `Mapping/` holds pure, stateless,
|
||||
internal-visibility mappers (each independently unit-tested); `Recorder/` holds the
|
||||
continuous-historization write path; the top-level files are the three public seams
|
||||
(`IHistorianGatewayClient`, the adapter, and the `GatewayHistorian` factory facade the Host consumes).
|
||||
`InternalsVisibleTo` is scoped to the test project only. The `ServerHistorianOptions` type living in
|
||||
`Runtime` rather than this driver (creating the documented Host→{Runtime, Gateway}, Gateway→Runtime
|
||||
diamond) is explained inline in the `.csproj` and is a reasonable ownership call, not a layering
|
||||
violation.
|
||||
|
||||
**9. Testing coverage.** See Driver.Historian.Gateway-003 (two offline-coverage gaps, now closed).
|
||||
Otherwise the suite is thorough: every mapper has a matrix-guard test for unmapped enum members,
|
||||
every writer's outcome-classification switch is covered by every named branch (including the
|
||||
FIFO-truncation semantics of the FasterLog outbox — append/peek/remove/restart-durability/capacity
|
||||
drop-oldest/periodic-commit-recovery all have dedicated tests), and the six env-gated
|
||||
`Category=LiveIntegration` tests (skip cleanly without `HISTGW_*` env vars, confirmed offline) cover
|
||||
the write/read/alarm/EnsureTags round trips against a real gateway per `docs/Historian.md`'s
|
||||
re-run recipe.
|
||||
|
||||
**10. Documentation & comments.** XML docs are extensive, accurate against the code they describe,
|
||||
and several carry load-bearing historical context (e.g. why each `GatewayHistorian.Create*` factory
|
||||
opens its own channel, why `Quality` is populated despite being server-ignored, why
|
||||
`HistorianEvent.Id` is deliberately left unset). No misleading or stale comment was found.
|
||||
|
||||
## Findings
|
||||
|
||||
### Driver.Historian.Gateway-001
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | High |
|
||||
| Category | Correctness & logic bugs |
|
||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Mapping/AlarmEventMapper.cs:39` (write side) and `Mapping/EventMapper.cs:62` (read side) |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `AlarmEventMapper.ToHistorianEvent` wrote the alarm's `Severity` wire property as
|
||||
`alarm.Severity.ToString()` — the .NET enum's *name* (`"Low"`/`"Medium"`/`"High"`/`"Critical"`).
|
||||
`AlarmHistorianEvent.Severity`'s own XML doc states the contract: "Mapped to Historian's numeric
|
||||
priority on the sink side." The read side, `EventMapper.ParseSeverity`, `int.TryParse`s that exact
|
||||
property and falls back to the minimum severity (1) on any unparseable value — which an enum name
|
||||
always is. So round-tripping any alarm event's severity through this module's own write mapper then
|
||||
its own read mapper silently collapsed every severity to the minimum, regardless of the real
|
||||
severity: a `Critical` alarm and a `Low` alarm would read back identically as severity 1. This is a
|
||||
self-contained defect (both mappers live in this module) independent of whether the gateway's own
|
||||
storage happens to echo arbitrary custom event properties back on `ReadEvents` — the write and read
|
||||
mappers disagreed with each other and with their own documented contract, and the existing test
|
||||
(`AlarmEventMapperTests.Maps_source_time_type_and_rich_properties`) asserted the *wrong* value
|
||||
(`"High"`) as correct, so no test caught it.
|
||||
|
||||
**Recommendation:** Map `AlarmSeverity` to a numeric OPC UA Part 9 priority string before writing it
|
||||
to the wire property, using the same severity convention already established elsewhere in the
|
||||
codebase for this exact enum (`ZB.MOM.WW.OtOpcUa.Runtime.Drivers.NativeAlarmProjector.MapSeverity`:
|
||||
Low=200, Medium=500, High=700, Critical=900).
|
||||
|
||||
**Resolution:** `55b12dc2` (branch `review/historian-gateway`), 2026-08-10. Added a private
|
||||
`AlarmEventMapper.ToNumericSeverity` mirroring `NativeAlarmProjector.MapSeverity`'s convention and
|
||||
routed the `Severity` property through it instead of `alarm.Severity.ToString()`. TDD: added
|
||||
`AlarmEventMapperTests.Severity_round_trips_through_EventMapper_as_numeric_priority` (a
|
||||
`[Theory]` over all four `AlarmSeverity` values, round-tripping through both mappers) and corrected
|
||||
the existing assertion to `"700"`; both were RED against the pre-fix code (confirmed by running the
|
||||
suite before the source change) and are GREEN after it. All 130 module tests pass
|
||||
(124 run + 6 skipped `LiveIntegration`).
|
||||
|
||||
### Driver.Historian.Gateway-002
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | Low |
|
||||
| Category | Performance & resource management |
|
||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayTagProvisioner.cs` |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** Unlike its two siblings — `GatewayHistorianDataSource` (`IAsyncDisposable`,
|
||||
disposes its `IHistorianGatewayClient`) and `GatewayAlarmHistorianWriter` (`IDisposable`, same) —
|
||||
`GatewayTagProvisioner` implemented neither `IDisposable` nor `IAsyncDisposable`, so the gRPC channel
|
||||
`GatewayHistorian.CreateProvisioner` opens for it was never closed. `IHistorianProvisioning` (the
|
||||
interface it's registered as) does not declare disposal either, so nothing in the codebase had any
|
||||
way to close that channel. Impact is bounded — `AddHistorianProvisioning` registers it as a single
|
||||
process-lifetime DI singleton (built once at startup, not per-deploy), so this was not an
|
||||
accumulating leak — but it is a real gap in graceful-shutdown hygiene and an inconsistency with the
|
||||
established pattern in this same module.
|
||||
|
||||
**Recommendation:** Implement `IAsyncDisposable` on `GatewayTagProvisioner`, disposing the
|
||||
underlying `IHistorianGatewayClient` — the built-in `Microsoft.Extensions.DependencyInjection`
|
||||
container tracks and disposes any singleton instance it constructs that implements
|
||||
`IDisposable`/`IAsyncDisposable`, regardless of the declared service-interface type, so no other
|
||||
wiring change is required.
|
||||
|
||||
**Resolution:** `55b12dc2` (branch `review/historian-gateway`), 2026-08-10. Added
|
||||
`GatewayTagProvisioner : IHistorianProvisioning, IAsyncDisposable` with a `DisposeAsync()` delegating
|
||||
to `_client.DisposeAsync()`. TDD: added
|
||||
`GatewayTagProvisionerTests.DisposeAsync_disposes_underlying_client`, confirmed RED (compile error —
|
||||
`GatewayTagProvisioner` did not implement `IAsyncDisposable`) before the fix, GREEN after.
|
||||
|
||||
### Driver.Historian.Gateway-003
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | Medium |
|
||||
| Category | Testing coverage |
|
||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Recorder/GatewayHistorianValueWriter.cs`, `Recorder/HistorizationOutboxEntrySerializer.cs` |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** Two production-critical pieces of the continuous-historization write path had no
|
||||
offline unit coverage:
|
||||
|
||||
- `GatewayHistorianValueWriter` — the recorder's `WriteLiveValues` adapter, and per CLAUDE.md's
|
||||
"KNOWN LIMITATION 2" history the exact class where the #491 `ConfigureAwait(false)`-destroys-`Self`
|
||||
defect and the #537/#538 gate findings were discovered live — was exercised only by the env-gated
|
||||
`Category=LiveIntegration` suite (`tests/.../Live/GatewayLiveIntegrationTests.cs`), which skips
|
||||
cleanly offline. Its ack-to-bool mapping (`Success || Queued`), timestamp-presence handling (a null
|
||||
timestamp must leave the proto field unset, not send a default), and non-throwing exception
|
||||
contract had zero offline verification.
|
||||
- `HistorizationOutboxEntrySerializer` — a hand-rolled fixed-layout little-endian binary
|
||||
(de)serializer with manual span-offset arithmetic — was only exercised *indirectly* through
|
||||
`FasterLogHistorizationOutboxTests`, and only ever with single-character ASCII tag names ("A"/"B"/
|
||||
"C"), never testing the one part of the layout arithmetic (the UTF-8 *byte*-length prefix vs. the
|
||||
tag's *char* length) that a multi-byte tag name would exercise.
|
||||
|
||||
**Recommendation:** Add direct offline unit tests for `GatewayHistorianValueWriter` (reusing the
|
||||
existing `FakeHistorianGatewayClient` test double already used by every other seam consumer in this
|
||||
module) and for `HistorizationOutboxEntrySerializer` (direct Serialize/Deserialize round trips,
|
||||
including a multi-byte UTF-8 tag name, an empty tag, and boundary `ushort`/`double` values).
|
||||
|
||||
**Resolution:** `a86a0976` (branch `review/historian-gateway`), 2026-08-10. Added
|
||||
`tests/.../Recorder/GatewayHistorianValueWriterTests.cs` (10 tests: success/queued/soft-failure ack
|
||||
mapping, exception swallowing, empty-batch short-circuit, timestamp presence/absence, quality/value
|
||||
forwarding, null-argument guards, dispose delegation) and
|
||||
`tests/.../Recorder/HistorizationOutboxEntrySerializerTests.cs` (4 tests: full round trip incl.
|
||||
`DateTimeKind` preservation, a multi-byte UTF-8 tag `"Pumpe_Ω_日本語_Temp"`, an empty tag, and
|
||||
`double.MinValue`/`ushort.MaxValue` boundaries). All 14 new tests pass; full module suite is
|
||||
124 passed / 6 skipped (LiveIntegration) / 0 failed.
|
||||
@@ -1,5 +1,7 @@
|
||||
# Code Review — Driver.Historian.Wonderware.Client.Contracts
|
||||
|
||||
> **Retired module — preserved as deleted-module history.** The bespoke Wonderware backend was retired in the HistorianGateway cutover (see CLAUDE.md §Historian: `ZB.MOM.WW.HistorianGateway` is the sole historian backend; `docs/drivers/Historian.Wonderware.md` is a retired stub). No `Historian.Wonderware*` project remains in `ZB.MOM.WW.OtOpcUa.slnx`; this review's Location references point at deleted files. Kept per the `Server`/`Admin` deleted-module precedent — findings are historical record, not re-reviewable. (Marked 2026-08-10.)
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Module | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Contracts` |
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Code Review — Driver.Historian.Wonderware.Client
|
||||
|
||||
> **Retired module — preserved as deleted-module history.** The bespoke Wonderware backend was retired in the HistorianGateway cutover (see CLAUDE.md §Historian: `ZB.MOM.WW.HistorianGateway` is the sole historian backend; `docs/drivers/Historian.Wonderware.md` is a retired stub). No `Historian.Wonderware*` project remains in `ZB.MOM.WW.OtOpcUa.slnx`; this review's Location references point at deleted files. Kept per the `Server`/`Admin` deleted-module precedent — findings are historical record, not re-reviewable. (Marked 2026-08-10.)
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Module | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client` |
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Code Review — Driver.Historian.Wonderware
|
||||
|
||||
> **Retired module — preserved as deleted-module history.** The bespoke Wonderware backend was retired in the HistorianGateway cutover (see CLAUDE.md §Historian: `ZB.MOM.WW.HistorianGateway` is the sole historian backend; `docs/drivers/Historian.Wonderware.md` is a retired stub). No `Historian.Wonderware*` project remains in `ZB.MOM.WW.OtOpcUa.slnx`; this review's Location references point at deleted files. Kept per the `Server`/`Admin` deleted-module precedent — findings are historical record, not re-reviewable. (Marked 2026-08-10.)
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Module | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware` |
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# Code Review — Driver.MTConnect.Contracts
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Module | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts` |
|
||||
| Reviewer | Claude (per-module review) |
|
||||
| Review date | 2026-08-10 |
|
||||
| Commit reviewed | `b990adae` |
|
||||
| Status | Complete |
|
||||
| Open findings | 0 |
|
||||
|
||||
## Checklist coverage
|
||||
|
||||
A comprehensive review completes every category, recording "No issues found" where
|
||||
a category produced nothing rather than leaving it blank.
|
||||
|
||||
| # | Category | Result |
|
||||
|---|---|---|
|
||||
| 1 | Correctness & logic bugs | No issues found. `TryFromTagConfig` cannot throw for any input: the only reachable failure of `JsonSerializer.Deserialize` over this all-scalar DTO is `JsonException`, which is caught, and the null/blank input is guarded before it. `SeparatorInsensitiveComparer.Equals` and `GetHashCode` hash and compare exactly the same characters (the classic bucket-mismatch bug is explicitly avoided and commented). `Infer`'s `(uint)sampleCount.Value` cannot overflow — `sampleCount` is an `int` the probe parser already constrained, and the cast is guarded by `> 0`. The `DATA_SET`/`TABLE` demotion is correctly checked **before** the category, so a `DATA_SET` `SAMPLE` cannot escape as `Float64`. |
|
||||
| 2 | OtOpcUa conventions | No issues found. Zero-dependency leaf over `Core.Abstractions` only, mirroring `Driver.Modbus.Contracts` / `Driver.AbCip.Contracts`; net10.0, nullable enabled, `TreatWarningsAsErrors`. No `JsonStringEnumConverter` on `JsonOptions` — the type-carrying field stays `string?` and is parsed by NAME, with a digits-only pre-check that rejects the numerically-serialized-enum shape before `Enum.TryParse` can accept `"8"` as an ordinal. The absent `GenerateDocumentationFile` matches every other `*.Contracts` project in the fleet (checked against Modbus/AbCip/S7/Mqtt), so it is convention rather than an omission. |
|
||||
| 3 | Concurrency & thread safety | No issues found. Every type here is immutable or stateless: two `sealed record`s, a `readonly record struct`, a `FrozenSet` built once in a static initialiser, and a stateless singleton comparer. No mutable static state, no I/O, no locks needed. |
|
||||
| 4 | Error handling & resilience | No issues found. `TryFromTagConfig` is total (never throws, returns a verdict), `Inspect` is total, and `IsAcceptedDataTypeName` treats blank as accepted so an unauthored type falls through to the Agent's own declaration rather than rejecting a tag. A present-but-invalid type rejects rather than silently defaulting — the sibling drivers' strict-enum posture, and the fix for Gitea #549. |
|
||||
| 5 | Security | No issues found. No I/O, no credentials, no logging, no reflection over untrusted input; the JSON reader is bounded by a fixed DTO shape. `Inspect`'s messages echo the operator's own authored value and the valid enum names, which is the intended operator-facing detail and carries nothing secret. |
|
||||
| 6 | Performance & resource management | No issues found. `MTConnectInferredType` is a `readonly record struct` so the discovery hot path allocates nothing; `NumericEventTypes` is a `FrozenSet` consulted through a comparer rather than by normalising the input, so the lookup needs no temporary string; `Infer` trims via `ReadOnlySpan<char>`. Nothing here is disposable. |
|
||||
| 7 | Design-document adherence | 1 finding — `Driver.MTConnect.Contracts-001`. The accept-gate-first contract from the #549–#559 arc is correctly implemented: `Inspect` asks `TryFromTagConfig` for the verdict and only explains a rejection, so it can never be stricter than the runtime; `IsAcceptedDataTypeName` is the single authority both the runtime and `MTConnectTagConfigModel.Validate` consult; and `FirstNonBlank(DriverDataType, DataType)` is deliberate, mirrored by the AdminUI model's load order and its normalise-onto-`dataType`-and-drop-the-alias save (#550). The `reference.TrimStart().FirstOrDefault() != '{'` early return in `Inspect` is likewise fleet convention — all ten driver factories carry the identical guard — so it was reviewed and not raised. |
|
||||
| 8 | Code organization & conventions | No issues found. The parse lives here as one authority rather than being mirrored into the AdminUI, which is exactly the drift the arc's design argues against; the DTO is private to the factory; the Options pattern is honoured and every collection defaults to empty rather than null. |
|
||||
| 9 | Testing coverage | No issues found. The project has no test project of its own (fleet convention for `*.Contracts`); it is covered from `Driver.MTConnect.Tests` (`MTConnectDataTypeInferenceTests` is a golden table including a probe-vs-streams spelling-parity case; `MTConnectRawTagBindingTests` and `MTConnectDriverOptionsTests` drive the factory) and from `AdminUI.Tests`, whose TagConfig parity corpus consumes `IsAcceptedDataTypeName` / `Inspect` through `MTConnectTagConfigModel`. Both suites were run after this review's changes: 538 and 1466 pass. |
|
||||
| 10 | Documentation & comments | 1 finding — `Driver.MTConnect.Contracts-001`. The XML documentation is otherwise excellent and records the *reason* for each rule — why the digits-only pre-check exists, why the alias out-ranks the canonical key, why the comparer is separator-insensitive (the probe document's `PART_COUNT` vs the streams document's `PartCount`), and why blank is accepted. |
|
||||
|
||||
## Findings
|
||||
|
||||
<!-- One #### entry per finding. IDs are Driver.MTConnect.Contracts-NNN, sequential within the
|
||||
module, never reused. Findings are never deleted — close them by changing Status and
|
||||
completing Resolution. -->
|
||||
|
||||
### Driver.MTConnect.Contracts-001
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | Low |
|
||||
| Category | Documentation & comments |
|
||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/MTConnectDataTypeInference.cs:31` · `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/MTConnectDriverOptions.cs:86` |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** Both public types documented a call graph that no longer exists, and in one case the
|
||||
false premise concealed a live defect rather than merely misinforming a reader.
|
||||
|
||||
1. `MTConnectDataTypeInference`'s class remarks justify living in `.Contracts` because "three call
|
||||
sites must agree byte-for-byte": `ITagDiscovery.DiscoverAsync`, "the universal browser's commit
|
||||
path (writes the browsed leaf into `TagConfig`)", and "the AdminUI typed tag editor (shows the
|
||||
inferred type and offers an override)". At HEAD a `grep` for `MTConnectDataTypeInference` outside
|
||||
this project returns only the driver: `RawBrowseCommitMapper` never calls it, and
|
||||
`MTConnectTagConfigModel` explicitly documents that it does **not** recompute the inference. Two
|
||||
of the three named callers do not exist.
|
||||
2. `MTConnectDriverOptions.RawTags` describes the coercion-type precedence and states that the
|
||||
`/probe`-inference step "is what makes a browse-committed tag work: `RawBrowseCommitMapper` writes
|
||||
only the address into the blob, so without it every value from the address picker would publish as
|
||||
text under a numerically-typed OPC UA node." That premise is false at HEAD — the mapper writes a
|
||||
full `MTConnectTagConfigModel` blob whose `dataType` defaults to `String` — and the sentence
|
||||
therefore asserts as *prevented* precisely the outcome that now happens on every browse commit.
|
||||
|
||||
The second is what makes this worth recording rather than a tidy-up: a reader auditing the browse
|
||||
path would have taken that remark as evidence the case was handled. The underlying defect is
|
||||
recorded as `Driver.MTConnect-003` (Open, fix belongs in `RawBrowseCommitMapper`).
|
||||
|
||||
**Recommendation:** Correct both remarks to describe the callers that exist, and — rather than
|
||||
deleting the precedence rule, which is still correct for hand-authored, CSV-imported and API-authored
|
||||
blobs — state explicitly that the step is not reached by a browse-committed tag today, with a pointer
|
||||
to the finding that tracks the cause.
|
||||
|
||||
**Resolution:** Fixed `6c63bf8f`, 2026-08-10 — `MTConnectDataTypeInference` now names its two real
|
||||
callers (`DiscoverAsync` and `MTConnectDriver.InferDefinition`) and carries a warning that the two
|
||||
it used to name do not call it; `MTConnectDriverOptions.RawTags` carries a warning that the
|
||||
`/probe` step is unreachable for a browse-committed tag at HEAD. Both point at
|
||||
`Driver.MTConnect-003`. Comment-only: `Driver.MTConnect.Tests` (538) and `AdminUI.Tests` (1466) both
|
||||
re-run green.
|
||||
@@ -0,0 +1,300 @@
|
||||
# Code Review — Driver.MTConnect
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Module | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect` |
|
||||
| Reviewer | Claude (per-module review) |
|
||||
| Review date | 2026-08-10 |
|
||||
| Commit reviewed | `b990adae` |
|
||||
| Status | Complete |
|
||||
| Open findings | 5 |
|
||||
|
||||
## Checklist coverage
|
||||
|
||||
A comprehensive review completes every category, recording "No issues found" where
|
||||
a category produced nothing rather than leaving it blank.
|
||||
|
||||
| # | Category | Result |
|
||||
|---|---|---|
|
||||
| 1 | Correctness & logic bugs | 3 findings — `Driver.MTConnect-002`, `Driver.MTConnect-003`, `Driver.MTConnect-006` |
|
||||
| 2 | OtOpcUa conventions | No issues found. `DriverType` and the factory's `DriverTypeName` are both aliased to `DriverTypeNames.MTConnect` (the `TwinCat`/`Focas` drift fix); every status code is a NAMED `const` matching an `Opc.Ua.StatusCodes` member, which the `StatusCodeParityTests` reflection guard requires; there is deliberately no `JsonStringEnumConverter` on the config reader, so enum-carrying DTO fields stay `string?` and go through `ParseEnum`. Authorization is never decided here — the driver reports `SecurityClassification` as browse metadata only, and says so explicitly. .NET 10 / AnyCPU, Serilog via `ILogger<T>` with a `NullLogger` default, no backend NuGet. |
|
||||
| 3 | Concurrency & thread safety | 1 finding — `Driver.MTConnect-005`. The lock discipline is otherwise sound and documented per field: `_lifecycle` (non-reentrant, never entered from the pump or the probe loop), `_subscriptionLock` (never held across an await or a callback; lock order `_lifecycle` → it; released before `StopSampleStreamAsync`), `_probeLock` (field read/write only). `_session` / `_binding` / `_index` are immutable snapshots published by `Volatile.Write` or compare-and-swap, so a lock-free reader cannot pair one initialize's client with another's model. Subscriber callbacks are raised outside every lock with the invocation list walked by hand, so one throwing consumer cannot starve the others. |
|
||||
| 4 | Error handling & resilience | 1 finding — `Driver.MTConnect-001`. Otherwise strong: failure is never dressed as success (a `/probe` that succeeds while the priming `/current` fails is Faulted, not "healthy with no data"); a quiet end of the `/sample` enumeration is reported rather than read as idle; an unreadable config document faults only a driver that had no working session; `Faulted` is reserved for a failed initialize because `DriverHealthReport` maps it to a `/readyz` 503; `/current` and `/sample` own separate degrade flags so neither papers over the other's failure. |
|
||||
| 5 | Security | 1 finding — `Driver.MTConnect-004` (a capability gate nobody can turn on, not a weakness). No credential is logged: `MTConnectDriverProbe` builds every message from a scheme/host/port/path redaction that excludes `Uri.UserInfo`, and its last-resort catch forwards only the exception TYPE. The Agent's refusal text reaches a log line, never `DriverHealth.LastError`. Write-back is off at two independent gates and CONDITION items are excluded. `XDocument.Parse`/`Load` run under the BCL default `DtdProcessing.Prohibit`, so the XML paths are not XXE-exposed, and the multipart reader caps its buffer at 32 MB against a hostile Agent. Every caller-supplied URL part is percent-encoded at the seam. |
|
||||
| 6 | Performance & resource management | 1 finding — `Driver.MTConnect-007`. Otherwise careful: one `/current` per read batch whatever the batch size; one shared `/sample` long poll behind every subscription handle; `FrozenDictionary` for the RawPath↔dataItemId tables; a separator-insensitive comparer that avoids allocating on the discovery hot path; a multipart reader that never buffers more than the part in flight. Each `CancellationTokenSource` is disposed only once its loop is provably gone (an abandoned loop still holds the token), and the two `HttpClient`s exist precisely so the unary deadline stays bounded while the long poll is not. |
|
||||
| 7 | Design-document adherence | 1 finding — `Driver.MTConnect-008` (stale claims, corrected). The implementation otherwise matches `docs/drivers/MTConnect.md` and `docs/plans/2026-08-05-mtconnect-writeback-design.md`: `IWritable` is observation injection through `AllowPut`, doubly gated and CONDITION-excluded; `RediscoverPolicy.Once` plus an `instanceId` watch; the dormant discovered-node injection path is correctly not relied on. |
|
||||
| 8 | Code organization & conventions | No issues found. Capability seams are implemented on the concrete type and the factory deliberately returns it un-narrowed, which is why `IWritable` was dispatched with no factory edit. The Options pattern is honoured, the config document has exactly one parser (`ParseOptions`, which the factory delegates to rather than mirroring), and the transport sits behind `IMTConnectAgentClient` so the whole driver is exercised with no socket. |
|
||||
| 9 | Testing coverage | No issues found. 538 tests across 14 classes cover the lifecycle, read, write, subscribe, discovery, probe, both parsers, the observation index and the multipart framing; each way the stream can be knocked off sequence (gap, `OUT_OF_RANGE`, restart, drop, not-supported) has a test, several with an explicit falsification control. Nothing waits on wall-clock time — the pump exposes its task as the teardown barrier and the probe cadence is an injected scheduler. The two gaps this review found are `Driver.MTConnect-001` / `-002`; both now have tests, each verified red first. |
|
||||
| 10 | Documentation & comments | 1 finding — `Driver.MTConnect-008`. The XML documentation is unusually thorough and states the reasoning (why a lock is not reentrant, why an exception beats an empty result, why a fallback exists) rather than restating the code. Both stale items found were premises another module had since invalidated, not drifted descriptions of this code. |
|
||||
|
||||
## Findings
|
||||
|
||||
<!-- One #### entry per finding. IDs are Driver.MTConnect-NNN, sequential within the module,
|
||||
never reused. Findings are never deleted — close them by changing Status and
|
||||
completing Resolution. -->
|
||||
|
||||
### Driver.MTConnect-001
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | Medium |
|
||||
| Category | Error handling & resilience |
|
||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs:1932` |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** The `/sample` pump's `StreamVerdict.Reopen` arm reset the reconnect ladder
|
||||
unconditionally (`attempt = 0; _reconnectAttempts = 0;`) and reopened the stream with no backoff.
|
||||
That is right for the two conditions the verdict was written for — a sequence gap and an Agent
|
||||
`OUT_OF_RANGE` rejection — because the `/current` re-baseline genuinely resolves both, so neither
|
||||
recurs immediately.
|
||||
|
||||
But the `catch (InvalidDataException)` in `ConsumeStreamAsync` that routes `OUT_OF_RANGE` to a
|
||||
re-baseline also catches two conditions a `/current` cannot resolve: a chunk
|
||||
`MTConnectStreamsParser` cannot parse (thrown from inside the `await foreach`) and a part larger
|
||||
than the multipart reader's 32 MB cap. Against an Agent producing either, the pump ran
|
||||
`/sample` → parse failure → `/current` (succeeds) → `Reopen` with the ladder reset → `/sample`
|
||||
again, **with no delay at any point** — an unbounded hot loop issuing two HTTP requests per
|
||||
iteration as fast as the Agent can answer, and one Warning per iteration. That is exactly the
|
||||
failure mode every other arm of this pump is written to prevent (the `_streamUnsupported` latch,
|
||||
`BackoffGrowthFloorMs`, the non-positive-cap fallback).
|
||||
|
||||
**Recommendation:** Use the discriminator the transient arm already uses — did *this* connection
|
||||
deliver a chunk? A re-baseline behind a stream that delivered stays an immediate reopen on a clean
|
||||
ladder; one on a connection that delivered nothing advances `BackoffFor` like any other failure.
|
||||
|
||||
**Resolution:** Fixed `6c63bf8f`, 2026-08-10 — the `Reopen` arm now sets
|
||||
`attempt = outcome.DeliveredAny ? 0 : attempt + 1`, so a re-baseline that resolved nothing backs off
|
||||
while a buffer roll behind a working stream still reopens instantly. Pinned by
|
||||
`A_rebaseline_that_delivers_nothing_still_advances_the_reconnect_backoff` (red before the change:
|
||||
`ReconnectAttempts` stayed 0 across six stream generations) plus the falsification control
|
||||
`A_rebaseline_after_a_delivered_chunk_still_reopens_immediately`, which fails any "back off on every
|
||||
re-baseline" over-correction.
|
||||
|
||||
### Driver.MTConnect-002
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | Medium |
|
||||
| Category | Correctness & logic bugs |
|
||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriverProbe.cs:192` |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `MTConnectDriverProbe` (the AdminUI Test Connect probe) parsed only `agentUri` from
|
||||
the driver config and requested `{AgentUri}/probe`, while `MTConnectAgentClient` narrows every real
|
||||
request to `{AgentUri}/{DeviceName}/probe`. A driver scoped to a device therefore had its
|
||||
reachability tested against a **different URL from the one it will use**.
|
||||
|
||||
The consequence is the failure-that-looks-like-success shape: a mistyped, since-renamed, or
|
||||
not-yet-deployed `deviceName` shows GREEN on Test Connect and then Faults the driver at
|
||||
`InitializeAsync` — a cppagent answers an unknown device path with an `MTConnectError` document
|
||||
under HTTP 200, which the parser surfaces as `InvalidDataException`. The type's own "reachable ≠
|
||||
deployable" remark did not cover this: the examples it names (`requestTimeoutMs`, a malformed tag
|
||||
entry) do not change the endpoint, and which endpoint is reached is the one thing a reachability
|
||||
probe exists to establish.
|
||||
|
||||
**Recommendation:** Read `deviceName` into the probe's minimal DTO and pass it into the
|
||||
`MTConnectDriverOptions` handed to `MTConnectAgentClient`, so the probe issues the driver's own
|
||||
request path. Keep the deliberate independence from `ParseOptions` — the distinction is "which keys
|
||||
compose the URL", not "which keys are cheap to read".
|
||||
|
||||
**Resolution:** Fixed `6c63bf8f`, 2026-08-10 — the probe DTO gained `DeviceName` and forwards it,
|
||||
and the type remarks now state the URL-composing rule. Pinned by
|
||||
`Configured_deviceName_scopes_the_probe_request_the_way_the_driver_does` (red before the change:
|
||||
requested `/probe`, expected `/VMC-1/probe`) and the control
|
||||
`An_absent_deviceName_leaves_the_probe_agent_wide`.
|
||||
|
||||
### Driver.MTConnect-003
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | High |
|
||||
| Category | Correctness & logic bugs |
|
||||
| Location | `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawBrowseCommitMapper.cs:256` (cause) · `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs:3286` (`BuildBinding` precedence) |
|
||||
| Status | Open |
|
||||
|
||||
**Description:** **Every browse-committed MTConnect tag is coerced to `String` at runtime while its
|
||||
OPC UA node is typed from the probe inference**, so a browsed `SAMPLE` publishes text into a
|
||||
`Double` node.
|
||||
|
||||
`RawBrowseCommitMapper.MapLeaf` does two things with one browsed leaf:
|
||||
|
||||
- it sets the raw tag row's OPC UA `DataType` from the leaf's inferred `DriverDataType`
|
||||
(`MapDataType`: `Float64` → `Double`, `Int64` → `Int64`); and
|
||||
- it calls `BuildTagConfig`, whose MTConnect branch is
|
||||
`new MTConnectTagConfigModel { FullName = address }.ToJson()`.
|
||||
|
||||
`MTConnectTagConfigModel.DataType` defaults to `DriverDataType.String` and `ToJson()`
|
||||
**unconditionally** writes `dataType`, so the committed blob is
|
||||
`{"fullName":"dev1_pos","dataType":"String"}`. That is a non-blank authored type, so
|
||||
`MTConnectTagDefinitionFactory.TryFromTagConfig` returns a definition, `BuildBinding` installs it as
|
||||
"the deploy-time authority", and step 3 of the documented coercion precedence — the Agent's own
|
||||
`/probe` declaration via `MTConnectDataTypeInference` — is **never reached for a browse-committed
|
||||
tag**. The driver's own remark that this step "is what makes a browse-committed tag work" rested on
|
||||
a premise (`RawBrowseCommitMapper` writes only the address) that was true when written and is false
|
||||
at HEAD.
|
||||
|
||||
This is the same defect class as Gitea #536, which is exactly why the AbCip, TwinCAT and FOCAS
|
||||
branches of `BuildTagConfig` forward the browsed native type via `NativeType(addressFields, …)`.
|
||||
MTConnect was given the typed-model branch without that forwarding.
|
||||
|
||||
Severity is High rather than Medium because the address picker is the primary authoring path for
|
||||
this driver, the mistyping is silent (no warning at authoring and none at the deploy gate — the blob
|
||||
is perfectly valid), and the resulting node advertises a numeric type it will never carry.
|
||||
|
||||
**Recommendation:** Cross-module — do not fix in this driver. In `RawBrowseCommitMapper`, forward
|
||||
the browsed leaf's `DriverDataType` into the MTConnect branch as the other three typed-model
|
||||
branches already do. `MapLeaf` already holds `driverDataType`; either thread it into
|
||||
`BuildTagConfig` alongside `addressFields`, or supply it through `addressFields` under the
|
||||
`dataType` key `NativeType` already reads. Add the MTConnect case to whatever test pins the #536
|
||||
behaviour for AbCip/TwinCAT/FOCAS, and assert the **pair** — that the tag row's OPC UA type and the
|
||||
blob's coercion type agree — rather than either alone: asserting only the blob would pass against a
|
||||
mapper that had stopped setting the row's type.
|
||||
|
||||
**Resolution:** _(open — the fix belongs in `ZB.MOM.WW.OtOpcUa.AdminUI`, outside this review's
|
||||
modules. The stale premises this defect invalidated inside these two modules are corrected under
|
||||
`Driver.MTConnect-008` and `Driver.MTConnect.Contracts-001`.)_
|
||||
|
||||
### Driver.MTConnect-004
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | Medium |
|
||||
| Category | Design-document adherence |
|
||||
| Location | `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/MTConnectDriverForm.razor:1` · `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/MTConnectDriverOptions.cs:146` |
|
||||
| Status | Open |
|
||||
|
||||
**Description:** `IWritable` shipped for this driver on 2026-08-05 (`b29623cf`) behind
|
||||
`Write.Enabled`, and `docs/drivers/MTConnect.md` §"Two independent gates" tells the operator to set
|
||||
`"Write": { "Enabled": true }` in the driver config. **There is no way to author that through the
|
||||
product.** `MTConnectDriverForm` is the only editor `DriverFormMaps` maps for this driver type, it
|
||||
exposes no write section, and its header comment still asserts "MTConnect is read-only — there are
|
||||
no write knobs to author". `DriverConfigModal` renders the typed form only (no raw-JSON escape
|
||||
hatch), and the AdminUI exposes no driver-instance API — its only `Map*` endpoints are the deploy
|
||||
and script-analysis ones. So the documented enablement path exists only as a direct edit of
|
||||
`DriverInstance.DriverConfig` in the config DB.
|
||||
|
||||
Mitigating, and worth knowing before fixing: `MTConnectDriverForm.BuildConfigJson` preserves unknown
|
||||
**top-level** keys, so a `write` block introduced by any other means is *not* destroyed by a later
|
||||
edit through the form. The capability is unreachable rather than fragile.
|
||||
|
||||
**Recommendation:** Cross-module — add a "Write-back" section to `MTConnectDriverForm` with an
|
||||
`Enabled` switch and form text stating that it is observation injection, that the Agent's own
|
||||
`AllowPut` is the other gate, and that it does not by itself make any node writable over OPC UA
|
||||
(`Tag.AccessLevel` still composes the `CurrentWrite` bit). Add `Write` to the form's mirrored
|
||||
`MTConnectConfigDto` so the value round-trips, and delete the now-false "MTConnect is read-only"
|
||||
comment. Until then the driver-side gate is correct and fails closed, which is why this is Medium
|
||||
rather than High.
|
||||
|
||||
**Resolution:** _(open — the fix belongs in `ZB.MOM.WW.OtOpcUa.AdminUI`, outside this review's
|
||||
modules.)_
|
||||
|
||||
### Driver.MTConnect-005
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | Low |
|
||||
| Category | Concurrency & thread safety |
|
||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs:2655` · `:689` · `:797` · `:1425` |
|
||||
| Status | Open |
|
||||
|
||||
**Description:** `_options` is the one piece of shared driver state published by a **plain** field
|
||||
write (`_options = options;` at the `StartCoreAsync` commit point) and read lock-free from other
|
||||
threads — `ReadAsync`, `WriteAsync`, `DiscoverAsync`, `HostName`, `DegradeAfterReadFailure`. Every
|
||||
other value published at that same commit point (`_session`, `_binding`, `_index`,
|
||||
`_announcedInstanceId`) uses `Volatile.Write` and is read with `Volatile.Read`, and the field
|
||||
remarks explain at length why that matters.
|
||||
|
||||
The practical exposure is small — a reference-typed field cannot tear, and the `Volatile.Write` of
|
||||
`_binding` two statements later acts as a release fence for it — but a reader that reads `_options`
|
||||
*before* it reads `_binding` (which both `ReadAsync` and `WriteAsync` do) has no acquire ordering
|
||||
between them, so it can in principle judge a batch against a stale options document paired with the
|
||||
new tag table. The worst observable outcome is one batch judged against the previous `Write.Enabled`
|
||||
or `RequestTimeoutMs`.
|
||||
|
||||
**Recommendation:** Publish and read `_options` with `Volatile.Write` / `Volatile.Read` like its
|
||||
three siblings, or fold it into the `AgentSession` / `TagBinding` snapshot so the "read once, as one
|
||||
consistent unit" rule this file already argues for covers it too. Record-only for now: the change
|
||||
touches ~10 read sites and the exposure does not justify unreviewed churn.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
|
||||
### Driver.MTConnect-006
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | Low |
|
||||
| Category | Correctness & logic bugs |
|
||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs:914` |
|
||||
| Status | Open |
|
||||
|
||||
**Description:** `TryFormatValue` refuses `null` and `Array`, then renders everything else with
|
||||
`Convert.ToString(value, InvariantCulture)`. Its own remark gives the right reason for the array
|
||||
guard — `Convert.ToString` falls back to `object.ToString()` for a non-`IConvertible` value, so an
|
||||
array would be injected into the Agent as the literal text `System.Double[]`, which the Agent stores
|
||||
happily and which stays invisible until someone reads it back. That reasoning is not specific to
|
||||
arrays: any other non-`IConvertible` value reaching this method (an `Opc.Ua.ExtensionObject`, an
|
||||
`XmlElement`, a `List<T>` from a non-SDK caller) is injected as its type name under a Good status.
|
||||
|
||||
Not reachable today by the production path — the SDK coerces a written value against the node's
|
||||
`DataType` before `DriverInstanceActor.HandleWriteAsync` builds the `WriteRequest`, and every
|
||||
MTConnect tag is a scalar Boolean/numeric/String — hence Low.
|
||||
|
||||
**Recommendation:** Refuse on the property the remark actually relies on rather than on one type
|
||||
that happens to have it: `if (value is null or Array or not IConvertible) return false;`. `string`,
|
||||
`bool`, every numeric and `DateTime` are `IConvertible`, so the accepted set is unchanged.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
|
||||
### Driver.MTConnect-007
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | Low |
|
||||
| Category | Performance & resource management |
|
||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectAgentClient.cs:85` |
|
||||
| Status | Open |
|
||||
|
||||
**Description:** `MTConnectAgentClient.Dispose()` is a plain check-then-set on a non-volatile
|
||||
`_disposed` field, so two concurrent disposals can both pass the guard. The second then calls
|
||||
`_disposeCts.Cancel()` on an already-disposed `CancellationTokenSource`, which throws
|
||||
`ObjectDisposedException`. The class documents that disposal is safe against an in-flight
|
||||
*enumeration* but claims nothing about concurrent disposal, and in practice the driver disposes only
|
||||
under `_lifecycle` and wraps the call in `SafeDispose`, so the throw is caught and traced — hence
|
||||
Low rather than Medium.
|
||||
|
||||
**Recommendation:** Guard with `Interlocked.Exchange(ref _disposed, 1) != 0` over an `int` field, or
|
||||
state on `Dispose` that it is not thread-safe and is called only under the driver's lifecycle lock.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
|
||||
### Driver.MTConnect-008
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | Low |
|
||||
| Category | Documentation & comments |
|
||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs:3210` · `docs/drivers/MTConnect.md:169` · `MTConnectDriver.cs:1227`/`:1245`/`:1255` |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** Three stale or self-contradicting pieces of documentation, each of which would lead
|
||||
a reader to a wrong conclusion:
|
||||
|
||||
1. `BuildBinding`'s remarks state that the probe-inference step of the coercion precedence "is what
|
||||
makes a browse-committed tag work: `RawBrowseCommitMapper` writes only the address into the
|
||||
blob". That premise is false at HEAD and the step is in fact dead for browse-committed tags — see
|
||||
`Driver.MTConnect-003`.
|
||||
2. `docs/drivers/MTConnect.md` §"FullName == DataItem `id`" says `address` "is the field name
|
||||
`RawBrowseCommitMapper` writes" for MTConnect. It writes `fullName`; `address` is the legacy
|
||||
spelling that must stay readable.
|
||||
3. `DiscoverAsync` reads `var options = _options;` once, with an explicit comment that a concurrent
|
||||
re-initialize must not scope half the walk to one device and half to another — then logs
|
||||
`_options.AgentUri` (the field) at all three of its log sites. Harmless in effect, but it
|
||||
contradicts the rule stated two lines above it, which is how such a rule gets broken later.
|
||||
|
||||
**Recommendation:** Correct (1) and (2) to describe HEAD and cross-reference the live defect rather
|
||||
than deleting the rule, which is still correct for hand-authored, CSV and API blobs; make (3) use
|
||||
the snapshot it deliberately took.
|
||||
|
||||
**Resolution:** Fixed `6c63bf8f`, 2026-08-10 — `BuildBinding` and `docs/drivers/MTConnect.md` now
|
||||
carry an explicit warning naming `Driver.MTConnect-003` and the mapper branch responsible, and the
|
||||
three `DiscoverAsync` log sites use the once-read `options` snapshot.
|
||||
@@ -0,0 +1,200 @@
|
||||
# Code Review — Driver.Mqtt.Browser
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Module | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser` |
|
||||
| Reviewer | Claude |
|
||||
| Review date | 2026-08-10 |
|
||||
| Commit reviewed | `b990adae` |
|
||||
| Status | Reviewed |
|
||||
| Open findings | 4 |
|
||||
|
||||
## Scope
|
||||
|
||||
The whole module: `MqttDriverBrowser.cs` (192 LOC), `MqttBrowseSession.cs` (1,213 LOC)
|
||||
and the csproj. No generated code in this project.
|
||||
|
||||
## Checklist coverage
|
||||
|
||||
A comprehensive review completes every category, recording "No issues found" where
|
||||
a category produced nothing rather than leaving it blank.
|
||||
|
||||
| # | Category | Result |
|
||||
|---|---|---|
|
||||
| 1 | Correctness & logic bugs | 2 findings — `Driver.Mqtt.Browser-001`, `-004` |
|
||||
| 2 | OtOpcUa conventions | No issues found. The browser parses the driver's `DriverConfig` through the shared `MqttJson.Options` rather than a private `JsonSerializerOptions`, which is what keeps the picker from accepting a `mode`/`protocolVersion` spelling the deployed driver rejects (the repo's documented systemic enum bug). `DriverType` is taken from `DriverTypeNames.Mqtt`, not a literal. No authorization decision is made here — `BrowserSessionService` enforces `DriverOperator`, and the session explicitly holds no user identity, matching the "authorization at the server layer, never in driver-level code" rule. |
|
||||
| 3 | Concurrency & thread safety | 2 findings — `Driver.Mqtt.Browser-003` and the ordering half of `-004`; both benign. The design is otherwise sound: the tree is built from `ConcurrentDictionary` with `StringComparer.Ordinal`, every mutation is lock-free, per-node observation records are swapped as whole references under `Volatile.Read`/`Write` so a UI thread never sees a half-written value, `_disposed` is an `Interlocked.Exchange` one-shot, and `Observe` is written to never throw on MQTTnet's dispatcher thread. |
|
||||
| 4 | Error handling & resilience | No issues found. `OpenAsync` bounds connect + subscribe with a clamped budget (5–30 s) and disposes the session — which owns the client — on any failure. `DisposeAsync` is idempotent, safe on a never-connected client, bounds the courtesy DISCONNECT at 5 s and swallows-and-logs both teardown steps. `ExpandAsync` returns empty for an unknown node id rather than throwing, correctly treating a stale id as an ordinary race against a tree that grows while it is browsed. |
|
||||
| 5 | Security | 1 finding — `Driver.Mqtt.Browser-001`. The module's central safety property is otherwise well built and genuinely checkable: every outbound message funnels through one private counted `PublishAsync`, reached only via a private nested `RebirthTransport` (so holding the session does not confer a publish-anything capability), and `PublishCountForTest` makes "browsing publishes nothing" assertable rather than aspirational. The Last-Will landmine is documented at length and guarded by a reflection test. Logs carry host/port/mode/filter only — never credentials or payload bodies. Metric names off the wire are **rejected** rather than scrubbed, precisely because a scrubbed name would be committed as a different binding key. |
|
||||
| 6 | Performance & resource management | 1 finding — `Driver.Mqtt.Browser-005`. Bounds are otherwise thorough and correctly reasoned: `PayloadInspectMaxBytes` caps decode work per message (the node cap alone would not, since a large payload on a known topic creates no node), `MaxBirthPayloadBytes` bounds the protobuf parse, `Utf8Strict` is cached rather than allocated per message, and `Scrub` stack-allocates under 128 chars. |
|
||||
| 7 | Design-document adherence | No issues found. Matches `docs/plans/2026-05-28-driver-browsers-design.md` and the MQTT design's §3.1/§4: births-only Sparkplug discovery (a DATA metric carries an alias and no name), node-level metrics hung directly under their edge node rather than under a synthesised "(node)" folder, no payload sniffing in Sparkplug mode, and `AddressFields` keyed by `MqttTagConfigKeys` so the commit mapper reads the address off fields rather than re-parsing a node id. The Gitea #512 layering fix is real and measured — the csproj comment even corrects its own earlier over-claim about `Core.dll`. |
|
||||
| 8 | Code organization & conventions | No issues found. Two files, one responsibility each (open/connect vs. accumulate/serve). The `MetricSeparator` (`::`) decision — so a node-level metric `Plant1/EdgeA/Temperature` cannot be confused with a device folder of the same shape — is correct and, importantly, nothing re-parses it: the rebirth target and the address tuple are both **read** off the scope stamped on the node, mirroring the v3 `AddressSpaceRealm` discipline. |
|
||||
| 9 | Testing coverage | No issues found. `MqttBrowseSessionTests` + `MqttBrowserLayeringTests` (in `Driver.Mqtt.Tests`, which reaches internals via `InternalsVisibleTo`) cover both tree shapes, the caps, the markers, rebirth scoping and the publish-count guarantee. The Sparkplug test seams feed a **real** encoded birth through the real `Observe` path — topic parse, protobuf decode and tree build all exercised rather than mocked around — which is why `-001` was reachable as a test at all. |
|
||||
| 10 | Documentation & comments | 1 finding — the false claim documented in `Driver.Mqtt.Browser-001` was in a *sibling* file (`SparkplugRebirthScope`) asserting a guarantee this module did not provide; it is now true. Otherwise the XML docs are unusually good, and several carry the reasoning that makes the code reviewable at all (why a browse client id must differ from the driver's; why a device folder states its kind rather than inferring it from child count; why a DataSet cell is always `{column}/{row}`). |
|
||||
|
||||
## Findings
|
||||
|
||||
<!-- One ### entry per finding. IDs are <Module>-NNN, sequential within the module,
|
||||
never reused. Findings are never deleted — close them by changing Status and
|
||||
completing Resolution. -->
|
||||
|
||||
### Driver.Mqtt.Browser-001
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | Medium |
|
||||
| Category | Security |
|
||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttBrowseSession.cs:898`, `:712` |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `ResolveRebirthTargets`' third branch — a bare `{group}/{edgeNode}` the
|
||||
window has never observed, honoured on purpose because a node that has not birthed is the
|
||||
prime rebirth target — gated its two segments on `IsUsableLabel`, which checks length and
|
||||
control characters only. MQTT wildcards passed. A scope of `Plant1/#` therefore assembled
|
||||
and published `spBv1.0/Plant1/NCMD/#`: a PUBLISH the MQTT spec forbids (MQTT-3.3.2-2),
|
||||
which a conformant broker answers by closing the connection, killing the operator's browse
|
||||
session mid-pick. `Plant1/+` is the worse shape — a broker that tolerates it would fan one
|
||||
operator click across every matching edge node, the exact broker-storm `MaxGroupRebirthNodes`
|
||||
exists to prevent, and with no cap applied because the group-enumeration branch was never
|
||||
entered.
|
||||
|
||||
What makes this more than theoretical is the documented division of responsibility.
|
||||
`SparkplugRebirthScope` (the AdminUI's typed input) rejects wildcards and states in its own
|
||||
remarks that it "is advisory, not the gate" because *"`MqttBrowseSession.ResolveRebirthTargets`
|
||||
re-resolves the scope and refuses an unusable one **before any message leaves**"* — a
|
||||
guarantee this module did not actually provide. Anything reaching `BrowserSessionService`
|
||||
without going through that Razor component (the API surface) had no wildcard check at all.
|
||||
|
||||
Branches 1 and 2 were never affected: their ids come off `SparkplugTopic.TryParse`, which
|
||||
already rejects `+`/`#` per segment.
|
||||
|
||||
**Recommendation:** Reject wildcard segments on the raw-input branch. Do **not** tighten
|
||||
`IsUsableLabel` itself — it also judges Sparkplug *metric* names, which are arbitrary
|
||||
publisher-chosen UTF-8, are never part of a topic, and legitimately contain characters a
|
||||
topic segment cannot; tightening it there would silently drop legitimate metrics from the
|
||||
picker tree.
|
||||
|
||||
**Resolution:** Fixed in `31182137` (2026-08-10) — a separate `IsUsableRebirthSegment`
|
||||
guards branch 3 (`IsUsableLabel` plus no `+`/`#`), leaving metric-name handling untouched;
|
||||
pinned by `MqttBrowseSessionTests.RequestRebirthAsync_WildcardScope_PublishesNothing` over
|
||||
four wildcard shapes, each asserting `PublishCountForTest == 0`.
|
||||
|
||||
### Driver.Mqtt.Browser-002
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | Low |
|
||||
| Category | Correctness & logic bugs |
|
||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttBrowseSession.cs:469` |
|
||||
| Status | Open |
|
||||
|
||||
**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
|
||||
through `IsUsableLabel`, whose doc explains the reason: "a metric name is arbitrary UTF-8
|
||||
chosen by the publisher, and an embedded newline or ANSI escape would break the picker's
|
||||
single-line rows." `ObservePlainTopic` applies only the length half of that (`MaxTopicChars`,
|
||||
`MaxSegmentChars`) and no control-character check, so a plain topic segment goes into
|
||||
`TopicNode.Segment` — the picker's displayed label — unfiltered. MQTT forbids only `U+0000`
|
||||
in a topic name, so a publisher can legally put `\n` or an ANSI escape in one.
|
||||
|
||||
Related and even smaller: a topic with an empty segment (`a//b`, or a trailing `/`) creates a
|
||||
node whose label is the empty string, rendering as a blank picker row. The committed address
|
||||
is still correct — `AddressFields[topic]` carries `node.Path`, the true topic — so this is a
|
||||
display defect, not a mis-binding.
|
||||
|
||||
**Recommendation:** Extend the existing pre-pass in `ObservePlainTopic` (which already walks
|
||||
every segment to check length, specifically so a rejected topic leaves no half-built path
|
||||
behind) to reject a segment containing a control character, setting the same `_oversized`
|
||||
marker. The plain-mode marker text would need widening from "Some topics were too long 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)_
|
||||
|
||||
### Driver.Mqtt.Browser-003
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | Low |
|
||||
| Category | Concurrency & thread safety |
|
||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttBrowseSession.cs:685` |
|
||||
| Status | Open |
|
||||
|
||||
**Description:** `GetOrCreate` reads `_nodeCount` and compares it to `_nodeCap`, then
|
||||
increments after a successful `GetOrAdd`. The read and the increment are not atomic with
|
||||
respect to each other, so N threads can each observe `_nodeCount == _nodeCap - 1` and all
|
||||
proceed, overshooting the cap by up to N-1 nodes. MQTTnet delivers on a single dispatcher
|
||||
per client, so N is 1 in practice today and the overshoot is unobservable; it becomes real
|
||||
only if a future change fans observation across threads.
|
||||
|
||||
There is a second, already-documented race in the same method: a node is inserted into its
|
||||
parent's `Children` before it is published to `_byPath`, so an `ExpandAsync` landing in that
|
||||
window returns empty for a node the tree already contains. The class remarks accept this
|
||||
explicitly ("a stale id is an ordinary race, not an error") and it self-heals on the next
|
||||
call, so it is noted rather than filed.
|
||||
|
||||
**Recommendation:** If the concurrency assumption ever changes, replace the read/increment
|
||||
pair with a single `Interlocked.Increment` compared against the cap, decrementing on the
|
||||
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)_
|
||||
|
||||
### Driver.Mqtt.Browser-004
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | Low |
|
||||
| Category | Correctness & logic bugs |
|
||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttBrowseSession.cs:1079` |
|
||||
| Status | Open |
|
||||
|
||||
**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
|
||||
Sparkplug mode two siblings *can* share a `Segment`: a device folder `Plant1/EdgeA/Temp` and
|
||||
an edge-node-level metric `Plant1/EdgeA::Temp` are different nodes with the same label.
|
||||
`OrderBy` is stable with respect to the input sequence, but the input is
|
||||
`ConcurrentDictionary.Values`, whose enumeration order is not guaranteed and can change as
|
||||
the tree grows. So two such siblings can swap places between successive `ExpandAsync` calls
|
||||
— the picker's list reorders under the operator mid-browse, and the two rows are visually
|
||||
identical, so it is not obvious which one is being clicked.
|
||||
|
||||
The comment on `Project` claims "ordered so the picker is stable", which is only true when
|
||||
labels are unique.
|
||||
|
||||
**Recommendation:** Make the sort total by adding `.ThenBy(n => n.Path, StringComparer.Ordinal)`,
|
||||
and/or order folders before leaves so a device and a same-named metric are visually
|
||||
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)_
|
||||
|
||||
### Driver.Mqtt.Browser-005
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | Low |
|
||||
| Category | Performance & resource management |
|
||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttBrowseSession.cs:59`, `:81` |
|
||||
| Status | Open |
|
||||
|
||||
**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`
|
||||
(up to `MaxTopicChars` = 1,024 chars), its `Segment` (up to 256) and a snippet (up to 64),
|
||||
plus its own `ConcurrentDictionary`. Every node is also held twice — once in its parent's
|
||||
`Children`, once in the flat `_byPath` index. A worst case near those bounds is on the order
|
||||
of 100+ MB for a single browse window, and a plain-mode open with no `TopicPrefix`
|
||||
subscribes `#`, i.e. the entire broker, which is exactly the shape that would approach it.
|
||||
Several concurrent pickers multiply it.
|
||||
|
||||
This is bounded and self-limiting, not a leak — `DisposeAsync` clears both maps and the
|
||||
session reaper (outside this module) bounds lifetime — and the `MaxTopicChars` remarks show
|
||||
the authors reasoned about precisely this ("50 000 nodes near that ceiling is a multi-GB
|
||||
tree"). What is missing is any bound on the *aggregate* across sessions.
|
||||
|
||||
**Recommendation:** No code change needed in this module. Worth confirming that
|
||||
`BrowserSessionService` caps concurrent sessions per user and that its idle reaper interval
|
||||
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)_
|
||||
@@ -0,0 +1,282 @@
|
||||
# Code Review — Driver.Mqtt.Contracts
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Module | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts` |
|
||||
| Reviewer | Claude |
|
||||
| Review date | 2026-08-10 |
|
||||
| Commit reviewed | `b990adae` |
|
||||
| Status | Reviewed |
|
||||
| Open findings | 4 |
|
||||
|
||||
## Scope
|
||||
|
||||
Handwritten sources only (~2,760 LOC across 16 `.cs` files): the top-level contract
|
||||
types (`MqttDriverOptions`, `MqttTagDefinition`, `MqttTagDefinitionFactory`,
|
||||
`MqttTagConfigKeys`, `MqttWriteEncoder`, `MqttJson`, `MqttLastWill`,
|
||||
`SparkplugDataType`, and the three enums) plus the handwritten `Sparkplug/` protocol
|
||||
layer (`SparkplugTopic`, `SparkplugCodec`, `SparkplugFlattener`,
|
||||
`SparkplugStatePayload`, `RebirthRequester`).
|
||||
|
||||
Generated code skipped:
|
||||
|
||||
- `obj/Debug/net10.0/Protos/SparkplugB.cs` — 6,873 LOC emitted by `Grpc.Tools`
|
||||
(`protoc`) from `Protos/sparkplug_b.proto` at build time (`GrpcServices="None"`,
|
||||
message-only). Build output, not checked in, never hand-edited.
|
||||
- `Protos/sparkplug_b.proto` — the **vendored** Eclipse Tahu schema. Read only to
|
||||
check the claims made about it elsewhere (proto2 explicit presence;
|
||||
`optional uint32 datatype = 4`); it is a third-party contract, not this repo's code.
|
||||
|
||||
Note the `Sparkplug/` subdirectory is **not** generated — those five files are
|
||||
handwritten and were reviewed in full.
|
||||
|
||||
## Checklist coverage
|
||||
|
||||
A comprehensive review completes every category, recording "No issues found" where
|
||||
a category produced nothing rather than leaving it blank.
|
||||
|
||||
| # | Category | Result |
|
||||
|---|---|---|
|
||||
| 1 | Correctness & logic bugs | 3 findings — `Driver.Mqtt.Contracts-001`, `-002`, `-006` |
|
||||
| 2 | OtOpcUa conventions | No issues found. `MqttJson.Options` is the single shared `JsonSerializerOptions` carrying `JsonStringEnumConverter`, anchored in `.Contracts` (the only assembly all four seams reference) — the documented systemic driver-enum-serialization bug is closed by construction. `SparkplugDataType` as a `global using` alias for the generated Tahu enum, rather than a hand-duplicated 35-member copy, is the right call and is drift-guarded by a reflection test. The assembly stays transport-free (no MQTTnet reference). |
|
||||
| 3 | Concurrency & thread safety | No issues found. Every type is `static` + pure or an immutable `record`; the only shared mutable state is `MqttJson.Options`, which `System.Text.Json` freezes on first use. `SparkplugCodec` / `SparkplugFlattener` hold no state and are written (and documented) to be safe on MQTTnet's shared dispatcher thread. |
|
||||
| 4 | Error handling & resilience | 1 finding — `Driver.Mqtt.Contracts-007`. Otherwise strong: both mappers and both codecs are total functions over arbitrary input. `SparkplugCodec.TryDecode`'s deliberately broad `catch (Exception)` spans the *projection* as well as the parse, which is correct at a dispatcher-thread boundary walking an attacker-shaped object graph. Zero-length-is-invalid and the bounded `MaxDepth`/`MaxMembers` flatten guards are both right. |
|
||||
| 5 | Security | 1 finding — `Driver.Mqtt.Contracts-001` (a wildcard PUBLISH is a protocol violation a broker answers by dropping the connection: a driver-wide outage triggered by one authored field). Credential handling is otherwise correct — `MqttDriverOptions.PrintMembers` is hand-written so `Password` renders as `***` (and distinguishably for null/empty), and prints `RawTags` as a count so a log of the DTO is not a config dump. No secret is logged anywhere in the assembly. |
|
||||
| 6 | Performance & resource management | No issues found. `SparkplugCodec` shares one empty member list rather than allocating per scalar metric; `BytesValue` is copied out of the `ByteString` because the projection outlives the parsed message; the flattener's `columns × rows` bound is computed through a `(long)` cast so it cannot overflow. Nothing here owns an `IDisposable`. `Inspect`'s `TrimStart().FirstOrDefault()` allocates, but runs once per tag at deploy time only. |
|
||||
| 7 | Design-document adherence | 1 finding — `Driver.Mqtt.Contracts-003`. Otherwise matches `docs/plans/2026-07-15-mqtt-sparkplug-driver-design.md` §3.1/§3.5/§3.6/§5.1–5.3 and the #549–#559 arc (strict enum + qos reads, accept-gate-first `Inspect`, structurally separated rejections/advisories, `JsonDocument.Parse` + root-`ValueKind` as the single framing authority). Those were reviewed **as shipped at HEAD** and are correct; nothing here reverts them. |
|
||||
| 8 | Code organization & conventions | No issues found. Clean layering: `.Contracts` holds the wire schema, the pure codecs and the pure mappers, and is referenced by the runtime driver, the transport, the browser and the AdminUI without any of them reaching back. `MqttTagConfigKeys` single-sources the address key names between producer (browse-commit mapper) and consumer (factory) — the drift it prevents is otherwise a silent `BadNodeIdUnknown`. |
|
||||
| 9 | Testing coverage | 1 finding — `Driver.Mqtt.Contracts-005` (the unenforced options annotations are untested because there is nothing to test). The module has no test project of its own by design; behaviour is pinned by `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests` (708 at base → 724 after this review) and the AdminUI TagConfig parity corpus (1,466). Coverage of the mappers, `Inspect`, the codec, the flattener, the topic parser and the golden Sparkplug vectors is genuinely thorough, including falsification controls. |
|
||||
| 10 | Documentation & comments | 1 finding — `Driver.Mqtt.Contracts-004` (a stated strictness principle the code does not apply to one key). XML docs are otherwise exceptionally accurate and carry the *reasons* for non-obvious decisions (why no `ToTagConfig` inverse; why the alias rather than a duplicate enum; why `is_null` wins over the value `oneof`; why a Template definition is refused; why the two STATE bodies share one timestamp). Every claim spot-checked against the code held. |
|
||||
|
||||
## Findings
|
||||
|
||||
<!-- One ### entry per finding. IDs are <Module>-NNN, sequential within the module,
|
||||
never reused. Findings are never deleted — close them by changing Status and
|
||||
completing Resolution. -->
|
||||
|
||||
### Driver.Mqtt.Contracts-001
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | High |
|
||||
| Category | Correctness & logic bugs |
|
||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttWriteEncoder.cs:119` |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `TryEncodePlain` checked only that `WriteTopic` was non-blank, then
|
||||
published to it verbatim. MQTT-3.3.2-2 forbids the wildcard characters `+`/`#` in a
|
||||
PUBLISH topic, and §4.8 requires a server encountering a protocol violation to close the
|
||||
network connection — which, for this driver, is the single connection every subscription
|
||||
lives on. So one write to a tag whose `writeTopic` carries a wildcard did not fail that
|
||||
tag; it dropped the whole driver's ingest session and took every tag on the device to Bad
|
||||
until the reconnect supervisor recovered.
|
||||
|
||||
The AdminUI's typed editor (`MqttTagConfigModel.ValidatePlain`) already refuses such a
|
||||
blob, and its own comment says a wildcard "would fail at the broker rather than here" —
|
||||
but the typed editor is one authoring path of several. The raw-JSON textarea, CSV import
|
||||
and the deployment API all persist a blob that never passes through it, and `Inspect` said
|
||||
nothing (see `-003`), so the value reached the encoder unchallenged.
|
||||
|
||||
**Recommendation:** Refuse it in the encoder, mapping to the existing
|
||||
`MqttWriteEncodeError.NoWriteDestination` (→ `BadNotWritable`) rather than inventing a new
|
||||
error code — the tag genuinely has nowhere a write could be sent, and
|
||||
`MqttDriver.MapEncodeError` already handles that member. Leave the read path untouched:
|
||||
the tag still subscribes and reads normally.
|
||||
|
||||
**Resolution:** Fixed in `6eace4bb` (2026-08-10) — `TryEncodePlain` refuses a wildcard
|
||||
`writeTopic` as `NoWriteDestination`, with the rule single-sourced in the new internal
|
||||
`MqttAddressRules.HasWildcard`; pinned by
|
||||
`MqttWriteEncoderTests.A_wildcard_write_topic_is_refused_rather_than_published`.
|
||||
|
||||
### Driver.Mqtt.Contracts-002
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | Medium |
|
||||
| Category | Correctness & logic bugs |
|
||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttWriteEncoder.cs:169`, `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/Sparkplug/SparkplugTopic.cs:202` |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** The same hole on the Sparkplug side, with one failure mode the Plain side
|
||||
does not have. `TryEncodeSparkplug` checked the binding tuple for *presence* only and
|
||||
handed it to `SparkplugTopic.Format`, which validates its arguments for null/empty but —
|
||||
unlike `TryParse`, which rejects a segment containing `+`/`#` — applies no character rule
|
||||
at all. So an authored `groupId` of `Ot/Sim` assembled `spBv1.0/Ot/Sim/NCMD/EdgeA`, a
|
||||
**well-formed topic addressing a different edge node** than the tag names, and the command
|
||||
went silently to the wrong device. A `+`/`#` in any of the three ids produced an
|
||||
unpublishable topic with the connection-drop consequence of `-001`.
|
||||
|
||||
A decoded incoming Sparkplug id can never contain any of those three characters (the topic
|
||||
parser splits on `/` and rejects wildcards), so an authored id that does is a binding that
|
||||
could never have resolved anyway — the AdminUI model says exactly this and refuses the
|
||||
save, but again only on the typed-editor path.
|
||||
|
||||
**Recommendation:** Validate the three id fields as single MQTT topic segments in the
|
||||
encoder and refuse as `NoWriteDestination`. `metricName` must be **exempt** — it rides in
|
||||
the payload, not the topic, and Sparkplug's own canonical names use `/` (e.g.
|
||||
`Node Control/Rebirth`), so a blanket rule would refuse every conformant node-control
|
||||
write.
|
||||
|
||||
**Resolution:** Fixed in `6eace4bb` (2026-08-10) — `TryEncodeSparkplug` rejects a
|
||||
`groupId`/`edgeNodeId`/`deviceId` containing `/`, `+` or `#` via
|
||||
`MqttAddressRules.IsIllegalTopicSegment` while exempting `metricName`; pinned by
|
||||
`MqttWriteEncoderTests.A_binding_tuple_that_is_not_a_legal_topic_segment_has_no_command_address`
|
||||
with `A_metric_name_containing_a_slash_still_encodes` as the falsification control.
|
||||
|
||||
### Driver.Mqtt.Contracts-003
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | Medium |
|
||||
| Category | Design-document adherence |
|
||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagDefinitionFactory.cs:361` |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `Inspect` — the deploy-time gate, consumed fleet-wide through
|
||||
`EquipmentTagConfigInspector` — reported a wildcard **subscription** `topic` but was silent
|
||||
about both rules `-001` and `-002` describe, even though the AdminUI's typed editor treats
|
||||
both as hard, save-blocking errors. That is the authoring-stricter-than-the-gate direction
|
||||
of the drift class the #549–#559 arc exists to close: a blob authored through the raw-JSON
|
||||
textarea, a CSV import or the API deploys clean and green, and the operator learns about it
|
||||
only when a write returns `BadNotWritable` (Plain) or the tag never leaves
|
||||
`BadNodeIdUnknown` (Sparkplug). The Sparkplug case is the worse of the two: a permanently
|
||||
dead tag with nothing anywhere saying why.
|
||||
|
||||
**Recommendation:** Add both as **advisories**, never rejections — the mappers accept these
|
||||
blobs and `Inspect`'s accept gate must never be stricter than the runtime (#551). Adding
|
||||
advisories is safe for the authoring side because `TagConfigParseGate.Describe` consults
|
||||
`Inspect` only *after* the parse has already refused, and never treats a non-empty
|
||||
inspection as an error.
|
||||
|
||||
**Resolution:** Fixed in `6eace4bb` (2026-08-10) — `DescribeAdvisories` gained a
|
||||
wildcard-`writeTopic` advisory and a per-key Sparkplug-segment advisory (the key list
|
||||
deliberately excludes `metricName`); pinned by
|
||||
`MqttInspectAcceptGateTests.A_wildcard_write_topic_is_advised_on_an_accepted_blob`,
|
||||
`A_sparkplug_id_that_is_not_a_topic_segment_is_advised`, and the control
|
||||
`A_metric_name_containing_a_slash_is_never_advised`.
|
||||
|
||||
### Driver.Mqtt.Contracts-004
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | Low |
|
||||
| Category | Documentation & comments |
|
||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagDefinitionFactory.cs:124`, `:234`, `:476` |
|
||||
| Status | Open |
|
||||
|
||||
**Description:** `retainSeed` is the one behavioural key still read leniently.
|
||||
`ReadBoolOrDefault` returns the default for any non-boolean JSON value, so
|
||||
`"retainSeed": "false"` (a string, which a CSV import or a hand-edited blob produces
|
||||
naturally) silently becomes `true` — the *opposite* of what was authored, and the tag then
|
||||
seeds its initial value from the broker's retained message when the author asked it not to.
|
||||
`payloadFormat`/`dataType` (via `TryReadEnumStrict`) and `qos` (via `TryReadQosStrict`) were
|
||||
deliberately made strict for exactly this reason, and the `qos` comment states the
|
||||
principle: silently absorbing a malformed value hands the operator a weaker guarantee than
|
||||
the one they authored, with nothing to show for it. `retainSeed` is not held to it, and no
|
||||
comment acknowledges the exception.
|
||||
|
||||
Recorded rather than fixed because the two surfaces are currently *consistent* — the
|
||||
AdminUI model reads `retainSeed` with the same leniency — so nothing is lying to the
|
||||
operator today, and tightening it changes an accept verdict, which this review should not
|
||||
do unilaterally.
|
||||
|
||||
**Recommendation:** Either (a) read it strictly, mirroring `TryReadQosStrict`'s
|
||||
absent/valid/present-but-invalid split, and mirror the same change in `MqttTagConfigModel`
|
||||
so both gates stay in step; or (b) add an `Inspect` advisory naming the key and the silent
|
||||
default; or (c) at minimum document the deliberate exception on `ReadBoolOrDefault`.
|
||||
Option (b) is lowest-risk — it changes no verdict.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
|
||||
### Driver.Mqtt.Contracts-005
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| 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 |
|
||||
|
||||
**Description:** `MqttDriverOptions` / `MqttPlainOptions` carry seven
|
||||
`System.ComponentModel.DataAnnotations` `[Range]` attributes and **nothing ever runs them**.
|
||||
Grepping `Validator.TryValidateObject` / `ValidationContext` across all four MQTT projects
|
||||
returns only the `using` directive in this file. The options are bound by
|
||||
`JsonSerializer.Deserialize` through `MqttJson.Options`, not by
|
||||
`IOptions` + `ValidateDataAnnotations`, so the attributes are documentation that reads like
|
||||
enforcement.
|
||||
|
||||
The consequence that actually escapes is `MqttPlainOptions.DefaultQos` (`[Range(0, 2)]`):
|
||||
it flows unclamped through `MqttSubscriptionManager` into `BuildFilters`, which propagates
|
||||
it to the SUBSCRIBE as-is, so a config carrying `"defaultQos": 5` puts an illegal QoS on
|
||||
the wire. Reachability is limited — the AdminUI's `MqttDriverFormModel` both validates
|
||||
("Default QoS must be 0, 1 or 2.") and `Math.Clamp`s it, and `MqttDriverProbe` range-checks
|
||||
`Port` — so only a hand-edited or API-supplied driver config reaches it. `[Range]` on
|
||||
driver options is also a repo-wide convention (`ModbusDriverOptions`, `S7DriverOptions` do
|
||||
the same), so this is not an MQTT-specific defect.
|
||||
|
||||
**Recommendation:** Do not fix in this module alone. Either run the annotations once at the
|
||||
composition seam every driver's config passes through (the factory extension) so the
|
||||
convention becomes enforcement fleet-wide, or drop the attributes in favour of the explicit
|
||||
checks the probe and the AdminUI form already carry. If neither, note on each attribute
|
||||
that it is descriptive. The enforcing consumer (`MqttSubscriptionManager.BuildFilters`)
|
||||
lives in `Driver.Mqtt`, outside this module.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
|
||||
### Driver.Mqtt.Contracts-006
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | Low |
|
||||
| Category | Correctness & logic bugs |
|
||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttWriteEncoder.cs:281` |
|
||||
| Status | Open |
|
||||
|
||||
**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
|
||||
large `ulong`, so writing `1969-12-31` publishes a timestamp roughly 584 million years in
|
||||
the future instead of being refused — the "unrepresentable values are refused, never
|
||||
coerced" rule the surrounding code states is not applied to the epoch bound. Second,
|
||||
`Convert.ToDateTime(value, InvariantCulture).ToUniversalTime()` treats a
|
||||
`DateTimeKind.Unspecified` input as *local* time, so the same authored instant encodes
|
||||
differently depending on the node's timezone — which matters on a redundant pair whose two
|
||||
nodes need not agree.
|
||||
|
||||
Impact is low: Sparkplug `DateTime` command metrics are rare, and the OPC UA write path
|
||||
supplies `DateTimeKind.Utc` values in practice.
|
||||
|
||||
**Recommendation:** Return `ValueNotRepresentable` when `ToUnixTimeMilliseconds()` is
|
||||
negative, and convert with an explicit `DateTime.SpecifyKind(…, DateTimeKind.Utc)` for an
|
||||
`Unspecified` input (or refuse it, which is more in keeping with the file's own posture).
|
||||
Add a test for each.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
|
||||
### Driver.Mqtt.Contracts-007
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | Low |
|
||||
| Category | Error handling & resilience |
|
||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/Sparkplug/SparkplugTopic.cs:202`, `:232` |
|
||||
| Status | Open |
|
||||
|
||||
**Description:** `SparkplugTopic` validates asymmetrically. `TryParse` enforces
|
||||
`IsValidSegment` (non-empty, no `+`/`#`) on every segment it reads, but `Format` and
|
||||
`FormatState` enforce only `ArgumentException.ThrowIfNullOrEmpty` on
|
||||
`groupId`/`edgeNodeId`/`hostId` and apply **no** check at all to `deviceId`. The type is
|
||||
therefore not round-trip-closed: `SparkplugTopic.Parse(x.ToTopicString())` can throw
|
||||
`FormatException` for an instance built by hand (a `deviceId` of `""` emits a trailing `/`;
|
||||
one containing `/` silently adds a segment). Every *production* caller is now guarded —
|
||||
`MqttWriteEncoder` since `-002`, `RebirthRequester` via the browser fix
|
||||
(`Driver.Mqtt.Browser-001`) — so this is a latent trap for the next caller rather than a
|
||||
live defect.
|
||||
|
||||
**Recommendation:** Apply `IsValidSegment` inside `Format`/`FormatState` (throwing
|
||||
`ArgumentException`, matching the existing contract) so the invariant lives on the type
|
||||
that owns it rather than in each caller. Note this makes `Format` throw where it previously
|
||||
succeeded, so the encoder's own guards must stay — `TryEncode` returns an error rather than
|
||||
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)_
|
||||
@@ -0,0 +1,132 @@
|
||||
# Code Review — Driver.Mqtt.Transport
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Module | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Transport` |
|
||||
| Reviewer | Claude Code |
|
||||
| Review date | 2026-08-10 |
|
||||
| Commit reviewed | `b990adae` |
|
||||
| Status | Reviewed |
|
||||
| Open findings | 1 |
|
||||
|
||||
## Checklist coverage
|
||||
|
||||
A comprehensive review completes every category, recording "No issues found" where
|
||||
a category produced nothing rather than leaving it blank.
|
||||
|
||||
| # | Category | Result |
|
||||
|---|---|---|
|
||||
| 1 | Correctness & logic bugs | Driver.Mqtt.Transport-002 |
|
||||
| 2 | OtOpcUa conventions | No issues found |
|
||||
| 3 | Concurrency & thread safety | No issues found |
|
||||
| 4 | Error handling & resilience | No issues found |
|
||||
| 5 | Security | Driver.Mqtt.Transport-001 |
|
||||
| 6 | Performance & resource management | No issues found |
|
||||
| 7 | Design-document adherence | No issues found |
|
||||
| 8 | Code organization & conventions | No issues found |
|
||||
| 9 | Testing coverage | No issues found |
|
||||
| 10 | Documentation & comments | No issues found |
|
||||
|
||||
#### Scope
|
||||
|
||||
One hand-written file, `MqttClientOptionsFactory.cs` (275 LOC), plus the csproj. The module is the
|
||||
leaf extracted by Gitea #512 so the runtime driver and the browse session assemble a broker
|
||||
connection through **one** copy of the TLS posture, the PEM CA-pin chain validator, the credential
|
||||
handling and the protocol-version mapping — a second copy would be a silent security divergence
|
||||
(browse quietly not honouring a pinned CA while the driver does). Nothing under `bin/` or `obj/`
|
||||
was reviewed.
|
||||
|
||||
#### Notes on the categories that produced nothing
|
||||
|
||||
Recorded rather than left implicit, because in a 275-line file "no issues" is a claim that should
|
||||
be checkable:
|
||||
|
||||
- **Concurrency (3).** The type is static and pure; the only mutable state is the
|
||||
`Lazy<X509Certificate2Collection?>` captured per validator, built with
|
||||
`LazyThreadSafetyMode.ExecutionAndPublication`, and one is created per `BuildClientOptions` call
|
||||
— i.e. per connect attempt — which is what makes a rotated CA file get picked up on the next
|
||||
connect rather than pinned for process life.
|
||||
- **Error handling (4).** Every failure on the pin path fails **closed**: an unreadable or empty PEM
|
||||
returns null and the validator refuses; a `CryptographicException` out of `X509Chain.Build` is
|
||||
caught and refused rather than escaping into the TLS callback as an opaque handshake crash; a
|
||||
non-chain `SslPolicyErrors` bit (hostname mismatch, absent certificate) stays fatal, so the pin
|
||||
replaces the trust *anchor* only.
|
||||
- **Performance (6).** One chain build per handshake, `owned?.Dispose()` in a `finally` for the
|
||||
copy made when the presented certificate is not already an `X509Certificate2`. No I/O on the
|
||||
build path at all, which is the property that lets a config-validation caller use it.
|
||||
- **Testing (9).** There is no `Driver.Mqtt.Transport.Tests` project; `BuildClientOptions` is
|
||||
covered from `Driver.Mqtt.Tests` (`MqttConnectionTests` drives the real validator against real
|
||||
self-signed chains — pinned CA accepted, stranger rejected, missing PEM fails closed, no I/O at
|
||||
build time — and `MqttBrowseSessionTests` covers the client-id suffix and forced clean session).
|
||||
That is genuine coverage of this module's behaviour through its two consumers, which is the shape
|
||||
the extraction was for; not recorded as a finding.
|
||||
- **Conventions / design (2, 7, 8).** Namespace `ZB.MOM.WW.OtOpcUa.Driver.Mqtt` (deliberately the
|
||||
same as `.Contracts` and `.Driver`, so the extraction was not a source-breaking change), .NET 10,
|
||||
`TreatWarningsAsErrors`, no MQTTnet leak into `.Contracts`. The csproj comment states the
|
||||
module's charter — connection *policy* only, nothing that connects — and the file honours it.
|
||||
|
||||
## Findings
|
||||
|
||||
### Driver.Mqtt.Transport-001
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | Medium |
|
||||
| Category | Security |
|
||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Transport/MqttClientOptionsFactory.cs:120-128` (`ConfigureTls`) |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `AllowUntrustedServerCertificate` and `CaCertificatePath` are independent settings,
|
||||
and when both are set the hatch wins **silently**: the branch installs
|
||||
`WithAllowUntrustedCertificates(true)` + `WithIgnoreCertificateChainErrors(true)` +
|
||||
`static _ => true` and returns before the pin is ever consulted. Every broker certificate is
|
||||
accepted, pinned chain or not, and nothing in any log says so.
|
||||
|
||||
The combination is not exotic. `allowUntrustedServerCertificate` is the dev/on-prem escape hatch an
|
||||
operator turns on while standing a broker up, and `caCertificatePath` is what they add afterwards to
|
||||
make MITM fail closed; forgetting to clear the first leaves a deployment that *looks* pinned in its
|
||||
config, passes review, and validates nothing. The failure is silent in the direction that matters —
|
||||
the operator's belief is "the pin is in force" and the reality is "any certificate is accepted" —
|
||||
and neither the driver's health surface nor Test-connect can distinguish the two.
|
||||
|
||||
Both knobs are explicit, so the fix is not to second-guess which one the operator meant: an
|
||||
implementation that let the pin override the flag would break a deployment that genuinely wants the
|
||||
hatch, and one that refused to build would fail a driver at connect over a config it has been
|
||||
accepting. Saying so, once, at the seam that resolves the conflict is the proportionate answer.
|
||||
|
||||
**Recommendation:** Warn (naming the pin path that is not being enforced) when
|
||||
`AllowUntrustedServerCertificate` is set and `CaCertificatePath` is non-blank, and leave the flag
|
||||
authoritative. The factory already takes an optional `ILogger` for exactly this class of deferred
|
||||
diagnostic, and never formats credentials.
|
||||
|
||||
**Resolution:** Fixed `74b85271`, 2026-08-10 — `ConfigureTls` logs a warning naming the shadowed CA
|
||||
path before taking the accept-everything branch; behaviour is otherwise unchanged. Verified no
|
||||
existing assertion depends on the branch being silent (713 tests green).
|
||||
|
||||
### Driver.Mqtt.Transport-002
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | Low |
|
||||
| Category | Correctness & logic bugs |
|
||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Transport/MqttClientOptionsFactory.cs:97-100` |
|
||||
| Status | Open |
|
||||
|
||||
**Description:** Credentials are attached only when `Username` is non-empty:
|
||||
`if (!string.IsNullOrEmpty(options.Username)) builder.WithCredentials(options.Username, options.Password ?? "")`.
|
||||
A config carrying a `Password` and **no** `Username` therefore connects anonymously, with the
|
||||
configured secret silently dropped. On MQTT 3.1.1 that is protocol-correct (a password may not be
|
||||
sent without a username), but this driver defaults to `ProtocolVersion = V500`, where a
|
||||
password-only CONNECT is legal and is how some brokers carry a token.
|
||||
|
||||
Impact is bounded — the broker either accepts the anonymous connect (in which case the deployment is
|
||||
less authenticated than its config says) or refuses it, which now surfaces as a classified CONNACK
|
||||
rejection rather than a mystery. It is recorded because the silent-drop direction is the one an
|
||||
operator cannot see from the config.
|
||||
|
||||
**Recommendation:** Warn when `Password` is non-empty and `Username` is blank, so the discarded
|
||||
credential is visible; optionally send password-only credentials under `V500`, where the protocol
|
||||
permits it. Do **not** log the password itself — nothing on this path formats it today, and the
|
||||
options record's `PrintMembers` override exists to keep it that way.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
@@ -0,0 +1,340 @@
|
||||
# Code Review — Driver.Mqtt
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Module | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt` |
|
||||
| Reviewer | Claude Code |
|
||||
| Review date | 2026-08-10 |
|
||||
| Commit reviewed | `b990adae` |
|
||||
| Status | Reviewed |
|
||||
| Open findings | 2 |
|
||||
|
||||
## Checklist coverage
|
||||
|
||||
A comprehensive review completes every category, recording "No issues found" where
|
||||
a category produced nothing rather than leaving it blank.
|
||||
|
||||
| # | Category | Result |
|
||||
|---|---|---|
|
||||
| 1 | Correctness & logic bugs | Driver.Mqtt-001, Driver.Mqtt-003 |
|
||||
| 2 | OtOpcUa conventions | No issues found |
|
||||
| 3 | Concurrency & thread safety | Driver.Mqtt-002, Driver.Mqtt-006 |
|
||||
| 4 | Error handling & resilience | Driver.Mqtt-004, Driver.Mqtt-007 |
|
||||
| 5 | Security | No issues found |
|
||||
| 6 | Performance & resource management | Driver.Mqtt-002 (leaked broker session per re-initialize) |
|
||||
| 7 | Design-document adherence | Driver.Mqtt-001 (docs/drivers/Mqtt.md's "ingest state is reset at every session seam" was not true of the plain path) |
|
||||
| 8 | Code organization & conventions | Driver.Mqtt-005 |
|
||||
| 9 | Testing coverage | Driver.Mqtt-008 |
|
||||
| 10 | Documentation & comments | Driver.Mqtt-009, Driver.Mqtt-010 |
|
||||
|
||||
#### Scope
|
||||
|
||||
The 11 hand-written source files under the module root and `Sparkplug/` (~8,100 LOC):
|
||||
`MqttDriver`, `MqttConnection`, `MqttSubscriptionManager`, `LastValueCache`,
|
||||
`MqttDriverFactoryExtensions`, `MqttDriverProbe`, and `Sparkplug/{AliasTable, BirthCache,
|
||||
SequenceTracker, SparkplugIngestor, SparkplugPrimaryHost}`. Nothing under `bin/` or `obj/` was
|
||||
reviewed (generated build output). `.Contracts` and `.Browser` are separate modules, read only
|
||||
where this module's behaviour depends on them.
|
||||
|
||||
#### What is already right, and worth not regressing
|
||||
|
||||
Recorded because several of these are the reason a defect below is a defect in one narrow spot
|
||||
only: the refused-CONNACK classification (MQTTnet 5 returns rather than throws), a deadline on
|
||||
every wait including teardown, the RawPath-as-published-reference discipline in both ingest
|
||||
paths, the alias-vs-name binding rule, the `Reinterpret` two's-complement call, "an invalid
|
||||
payload never mutates state", the per-node rebirth debounce *and* its lifetime cap, and the
|
||||
dispatcher-thread containment (nothing throws, blocks, or does I/O on MQTTnet's pump).
|
||||
|
||||
## Findings
|
||||
|
||||
### Driver.Mqtt-001
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | High |
|
||||
| Category | Correctness & logic bugs |
|
||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttSubscriptionManager.cs:193` (`AttachTo`), `:328` (`SubscribeAsync`'s pending set) |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** In Plain mode the manager dedupes SUBSCRIBEs against `_subscribedTopics` — a set
|
||||
that describes **the session it established them on** — but `AttachTo` never cleared it, and the
|
||||
manager *outlives* a session rebuild. `MqttDriver.AdoptOptions` replaces the manager only when the
|
||||
ingest identity changed, so both routes that rebuild the broker session re-attach the *same*
|
||||
instance to a *new* connection: `ReinitializeAsync`'s rebuild branch (endpoint / credentials / TLS /
|
||||
timeout change — `SessionIdentity` is a strict superset of `IngestIdentity`) and, more importantly,
|
||||
the driver-host's `ShutdownAsync` + `InitializeAsync` retry, which the #527 comms-loss watchdog now
|
||||
makes automatic and unattended.
|
||||
|
||||
After such a rebuild the host re-applies its desired subscriptions
|
||||
(`DriverInstanceActor.InitializeSucceeded` → `ResubscribeDesired`), `SubscribeAsync` computed
|
||||
`pending = defs.Where(d => !_subscribedTopics.ContainsKey(d.Topic))` — empty, because every topic was
|
||||
still marked established from the *dead* session — and `EstablishAsync([])` returned immediately
|
||||
having sent nothing. The result is exactly the failure this file's own remarks exist to prevent: a
|
||||
driver reporting `Connected` / `Healthy` on a broker session carrying **zero subscriptions**,
|
||||
receiving nothing, with no error, no exception and no Bad status. It self-corrected only if the
|
||||
*new* session later dropped, at which point `OnReconnectedAsync` finally re-established the filters —
|
||||
so a clean reconnect left every value this driver serves frozen indefinitely.
|
||||
|
||||
`SparkplugIngestor.AttachTo` already performed the equivalent reset (`ResetSessionState`) for its own
|
||||
per-session state, with remarks spelling out this same "the ingestor outlives a session rebuild"
|
||||
reasoning. The plain path was never given the same treatment.
|
||||
|
||||
**Recommendation:** Clear `_subscribedTopics` at the top of `AttachTo`, before delivery is wired,
|
||||
mirroring `SparkplugIngestor.AttachTo`. Pin it with a test that rebinds the manager to a new
|
||||
connection and asserts the next subscribe actually reaches the transport, plus a control proving the
|
||||
within-one-session dedupe still holds — otherwise the "fix" is indistinguishable from deleting the
|
||||
dedupe, which re-triggers every topic's retained seed on every subscribe.
|
||||
|
||||
**Resolution:** Fixed `2b4b3fcb`, 2026-08-10 — `AttachTo` forgets the previous session's established
|
||||
filters. Pinned by `MqttSubscriptionManagerTests.AttachTo_ForgetsTheFiltersEstablishedOnThePreviousSession`
|
||||
(confirmed to fail against the pre-fix code) with `SubscribeAsync_TwiceOnTheSameSession_StillIssuesOneSubscribe`
|
||||
as the falsifiability control.
|
||||
|
||||
### Driver.Mqtt-002
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | High |
|
||||
| Category | Concurrency & thread safety |
|
||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs:1000` (`_connection = connection`), `:1297` (`_primaryHost = host`) |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `InitializeCoreAsync` published `_connection` and `_primaryHost` — both
|
||||
`IAsyncDisposable`, both owning a live broker socket and a running reconnect supervisor — **over
|
||||
whatever the driver already held**, without disposing it. A second initialize on a live driver
|
||||
therefore stranded a fully connected, self-supervising session that no later `ShutdownAsync` or
|
||||
`DisposeAsync` could reach (both act only on the current field value), and whose own supervisor kept
|
||||
it alive against the broker indefinitely. For a Sparkplug driver the orphan also retains its
|
||||
group-wide `spBv1.0/{group}/#` subscription and a *second* primary-host claim on the same host id —
|
||||
and two MQTT clients presenting one client id take turns evicting each other, forever.
|
||||
|
||||
CLAUDE.md records this as latent on the grounds that `DriverInstanceActor` passes `teardownFirst` on
|
||||
the #527 retry path. That covers the retry path only: `AdoptConfigDuringInit` re-runs
|
||||
`InitializeAsync` with **no** teardown when an `ApplyDelta` arrives while the driver is in
|
||||
`Connecting`/`Reconnecting` (`DriverInstanceActor.cs:743`) — an ordinary redeploy racing a reconnect.
|
||||
Depending on every caller to tear down first is also the wrong shape: the driver owns those fields
|
||||
and is the only thing that can dispose them.
|
||||
|
||||
**Recommendation:** `await TeardownAsync()` at the top of `InitializeCoreAsync`, under the lifecycle
|
||||
gate both callers already hold. It is idempotent (`Interlocked.Exchange` to null), so
|
||||
`ReinitializeAsync`'s rebuild branch tearing down first costs a no-op and a first initialize sees
|
||||
three nulls.
|
||||
|
||||
**Resolution:** Fixed `2b4b3fcb`, 2026-08-10 — `InitializeCoreAsync` tears down the previous
|
||||
connection, host-probe loop and primary-host claim before building the new session. Pinned by
|
||||
`MqttDriverSessionLifecycleTests.InitializeAsync_OnALiveDriver_TearsDownThePreviousSession`, which
|
||||
asserts the **broker's** live socket count (a leak is by definition the case where the driver's own
|
||||
view disagrees with the broker's) and fails against the pre-fix code.
|
||||
|
||||
### Driver.Mqtt-003
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | Medium |
|
||||
| Category | Correctness & logic bugs |
|
||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs:376,903`; `MqttConnection.cs:474,486,657,1039,1134,1368,1384`; `Sparkplug/SparkplugPrimaryHost.cs:267` |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `MqttDriverOptions.ConnectTimeoutSeconds` carries `[Range(1, int.MaxValue)]`, but
|
||||
nothing validates it on the runtime path — the blob is deserialized straight out of the deploy
|
||||
artifact by `MqttJson.Options`. Every bounded wait in the module then feeds it to
|
||||
`new CancellationTokenSource(TimeSpan.FromSeconds(...))`, `SemaphoreSlim.WaitAsync(TimeSpan)` or
|
||||
`Task.WaitAsync(TimeSpan)` unchecked:
|
||||
|
||||
- `0` yields an already-cancelled deadline, so connect / subscribe / publish fail instantly against a
|
||||
perfectly healthy broker and the driver reports a timeout it invented;
|
||||
- a negative value is outside the legal range of both APIs, so it throws
|
||||
`ArgumentOutOfRangeException` out of `ShutdownAsync` and `DisposeAsync` — the two paths whose whole
|
||||
job is to complete, and the ones a host calls while tearing a bad deployment down.
|
||||
|
||||
The module already clamps its other numeric knobs at their use sites (`MaxPayloadBytes` in the
|
||||
manager's constructor, min/max backoff in `NextBackoff`, QoS in `MapQos`, and the probe's own
|
||||
`ConnectTimeoutSeconds > 0 ? … : fallback`), so this knob is the outlier rather than the convention.
|
||||
|
||||
**Recommendation:** Normalize once, at the single seam every options instance the driver holds passes
|
||||
through, rather than at each of the ten use sites — the value rides into `MqttConnection`,
|
||||
`SparkplugPrimaryHost` and `MqttClientOptionsFactory` through `_options`.
|
||||
|
||||
**Resolution:** Fixed `2b4b3fcb`, 2026-08-10 — `MqttDriver.Normalize` replaces a non-positive
|
||||
`connectTimeoutSeconds` (and a negative `keepAliveSeconds`; zero keep-alive is legal MQTT) with the
|
||||
record's own defaults, applied in the constructor and in `ParseOptions`. Pinned by
|
||||
`MqttDriverSessionLifecycleTests.ShutdownAndDispose_WithANegativeConnectTimeout_DoNotThrow`, with
|
||||
`InitializeAsync_WithAnAuthoredConnectTimeout_KeepsIt` as the control that a legal value is not
|
||||
quietly rewritten to the default.
|
||||
|
||||
### Driver.Mqtt-004
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | Low |
|
||||
| Category | Error handling & resilience |
|
||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs:375` (`ShutdownAsync`) |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `ShutdownAsync` captured the last-message instant with `ReadHealth()` — the *stored*
|
||||
snapshot, which is only ever written at initialize/shutdown — rather than `GetHealth()`, which
|
||||
prefers the live `MqttConnection.LastMessageUtc`. The stored value is whatever was true when the
|
||||
session was established (usually `null`), so a shutdown discarded the evidence that the driver had
|
||||
been delivering right up to teardown and reported a driver that was receiving a second ago as one
|
||||
that had never received anything. That timestamp is what the #527 comms-loss watchdog measures a
|
||||
driver against, and shutdown-then-initialize is precisely its retry cycle.
|
||||
|
||||
**Recommendation:** Use `GetHealth().LastSuccessfulRead`; the connection is still live at that point
|
||||
in the method.
|
||||
|
||||
**Resolution:** Fixed `2b4b3fcb`, 2026-08-10 — one-line change plus a comment naming why the two
|
||||
health reads are not interchangeable here.
|
||||
|
||||
### Driver.Mqtt-005
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | Low |
|
||||
| Category | Code organization & conventions |
|
||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttConnection.cs:1-4` |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** Three `using` directives left behind when the TLS / CA-pin code moved out to the
|
||||
`.Transport` module (Gitea #512) — `System.Net.Security`, `System.Security.Cryptography` and
|
||||
`System.Security.Cryptography.X509Certificates` — no longer resolve anything in this file. They read
|
||||
as though this type still does certificate work, which is exactly the layering claim #512 removed.
|
||||
(`System.Buffers` looks equally unused and is not: `ReadOnlySequence<byte>.ToArray()` is
|
||||
`BuffersExtensions`', not a member — removing it breaks the build.)
|
||||
|
||||
**Recommendation:** Drop the three, and leave a note on `System.Buffers` so the next reader does not
|
||||
repeat the experiment.
|
||||
|
||||
**Resolution:** Fixed `2b4b3fcb`, 2026-08-10 — the three unused usings removed; `System.Buffers` kept
|
||||
with a comment naming the extension method that needs it. Build verified.
|
||||
|
||||
### Driver.Mqtt-006
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | Low |
|
||||
| Category | Concurrency & thread safety |
|
||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/SparkplugPrimaryHost.cs:278` |
|
||||
| Status | Open |
|
||||
|
||||
**Description:** `DisposeAsync` calls `_gate.Dispose()`. `StopAsync` cancels the reconcile loop but
|
||||
does not await it, so a `ReconcileAsync` already in flight can be sitting in
|
||||
`ClaimAsync`/`ReleaseAsync`'s `_gate.WaitAsync(...)`, or on its way to the
|
||||
`finally { _gate.Release(); }`, when the gate is disposed — surfacing an `ObjectDisposedException`
|
||||
naming `SemaphoreSlim`, which tells an operator nothing about what happened. It is contained (both
|
||||
`ReconcileAsync` and `ReconcileLoopAsync` catch broadly and log) and no claim can be created after
|
||||
dispose (`ClaimAsync` re-checks `_disposed` under the gate), so the impact is a misleading warning
|
||||
rather than a leak.
|
||||
|
||||
Worth recording because the sibling type reached the opposite conclusion **deliberately**:
|
||||
`MqttConnection` documents that its `_lifecycleGate`, `_reconnectWake` and `_lifetimeCts` are *never*
|
||||
disposed, precisely so a late callback racing teardown produces a benign no-op instead of an
|
||||
`ObjectDisposedException` naming the wrong type. Two lifecycle types in one module disagreeing about
|
||||
this is the kind of divergence that gets copied into the third.
|
||||
|
||||
**Recommendation:** Either stop disposing `_gate` (matching `MqttConnection`'s documented rule and its
|
||||
stated rationale — the gate holds no timer and no surviving registration), or have `StopAsync` await
|
||||
the reconcile loop before dispose returns. The former is the smaller change and the one already
|
||||
argued for in this module.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
|
||||
### Driver.Mqtt-007
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | Low |
|
||||
| Category | Error handling & resilience |
|
||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttSubscriptionManager.cs:406-424` (`OnReconnectedAsync`) |
|
||||
| Status | Open |
|
||||
|
||||
**Description:** When the reconnect path finds live subscribed topics but resolves **zero** filters
|
||||
for them, it logs a warning and returns normally. The type's own contract, stated two paragraphs
|
||||
later, is that a reconnect which re-establishes nothing must *throw*, so `MqttConnection` tears the
|
||||
fresh session down and retries rather than serving a connected-but-deaf driver — and this branch
|
||||
reaches that same outcome by a different route (no subscription is re-established either way). The
|
||||
asymmetry is deliberate as written ("Silence here is how the wildcard defect hid"), but the remedy it
|
||||
chose was a louder log rather than the teardown the failure warrants.
|
||||
|
||||
In practice the branch is close to unreachable — `Register` prunes `_subscribedTopics` of exactly the
|
||||
topics missing from `ByFilter`, and with Driver.Mqtt-001 fixed a rebind clears the set outright —
|
||||
which is an argument for leaving the code alone, not for leaving the contract ambiguous.
|
||||
|
||||
**Recommendation:** Throw here as the zero-granted case does, or state explicitly in the remarks why
|
||||
*this* deaf-reconnect shape is exempt from the type's own rule. Do not leave the two answers side by
|
||||
side unexplained.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
|
||||
### Driver.Mqtt-008
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | Low |
|
||||
| Category | Testing coverage |
|
||||
| Location | `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/` (708 tests at `b990adae`) |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** Coverage is genuinely strong (golden Sparkplug vectors, the §3.6 message matrix,
|
||||
CONNACK classification, backoff, retained-seed handling, write encoding, the accept-gate). The gap
|
||||
that mattered is that **`MqttSubscriptionManager.AttachTo` had no test at all** — grepping `AttachTo`
|
||||
across the suite matched only `SparkplugIngestorTests`. That is why Driver.Mqtt-001 survived: the
|
||||
Sparkplug half of the same seam is pinned by `AttachTo_DropsBirthsFromThePreviousSession`, and the
|
||||
plain half was not. Nothing covered a *driver-level* second initialize either (Driver.Mqtt-002),
|
||||
because every driver-lifecycle test initializes exactly once.
|
||||
|
||||
The shape of the gap is worth naming: both defects live in "what happens to state that outlives the
|
||||
session it describes", and both are invisible to a suite whose fixtures build one session per test.
|
||||
|
||||
**Recommendation:** Cover every session-rebind seam on *both* ingest paths, and assert
|
||||
re-initialization against an observable the driver does not own (the fake broker's live socket
|
||||
count), since a leak is by definition the case where the driver's own view is wrong.
|
||||
|
||||
**Resolution:** Fixed `2b4b3fcb`, 2026-08-10 — added
|
||||
`MqttSubscriptionManagerTests.AttachTo_ForgetsTheFiltersEstablishedOnThePreviousSession` and
|
||||
`…SubscribeAsync_TwiceOnTheSameSession_StillIssuesOneSubscribe`, plus the new
|
||||
`MqttDriverSessionLifecycleTests` (3 tests). All three regression tests were confirmed to fail
|
||||
against the un-fixed production code, and both controls to pass in either state. Suite 708 → 713
|
||||
green.
|
||||
|
||||
### Driver.Mqtt-009
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | Low |
|
||||
| Category | Documentation & comments |
|
||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttConnection.cs:241-248` (type remarks) |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** The remarks on the publish leg stated "This driver has no `IWritable` leg in v1, so
|
||||
a Sparkplug rebirth NCMD is the **only** outbound application message it ever sends." Gitea #508
|
||||
added the write leg — `MqttDriver.WriteAsync` calls `connection.PublishAsync` for every OPC UA write
|
||||
— and #511 added a third caller in `SparkplugPrimaryHost`'s retained STATE. A comment that
|
||||
under-states what a method is used for is how a later "this is only ever called from X, so it can
|
||||
assume Y" change gets made.
|
||||
|
||||
**Recommendation:** Enumerate the three production callers instead, and date the "no `IWritable` leg"
|
||||
claim as historical.
|
||||
|
||||
**Resolution:** Fixed `2b4b3fcb`, 2026-08-10 — remarks now name all three callers and mark the
|
||||
retired claim against #508.
|
||||
|
||||
### Driver.Mqtt-010
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | Low |
|
||||
| Category | Documentation & comments |
|
||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/SequenceTracker.cs:294` (`Reset`) |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `Reset()`'s remarks call it "the reconnect seam (§3.6 invariant #5 — late join)",
|
||||
but the reconnect path does not call it: `SparkplugIngestor.ResetSessionState` clears the whole
|
||||
`_trackers` map instead. Its only caller in the tree is `SequenceTrackerTests`. The reasoning in the
|
||||
remarks is correct and load-bearing — it is the attribution that is wrong, and it sends a reader
|
||||
looking for the reconnect reset to the wrong method.
|
||||
|
||||
**Recommendation:** Keep the method and the rationale; state that the ingestor drops the map rather
|
||||
than resetting instances, and why (dropping also releases the entry for a node the plant stopped
|
||||
publishing).
|
||||
|
||||
**Resolution:** Fixed `2b4b3fcb`, 2026-08-10 — remarks corrected; no code change.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Code Review — Driver.OpcUaClient.Session
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Module | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session` |
|
||||
| Reviewer | Claude (automated review agent) |
|
||||
| Review date | 2026-08-10 |
|
||||
| Commit reviewed | `b990adae` |
|
||||
| Status | Reviewed |
|
||||
| Open findings | 0 |
|
||||
|
||||
## Checklist coverage
|
||||
|
||||
| # | Category | Result |
|
||||
|---|---|---|
|
||||
| 1 | Correctness & logic bugs | No issues found. `ToStableReference`/`TryResolve` correctly implement the namespace-URI-stable NodeId encoding described in `docs/v2/driver-specs.md` §8: namespace 0 keeps the compact `i=…` form (spec-fixed, never reorders), a nonzero namespace is rewritten to `nsu=<uri>;<idType>=<identifier>`, an out-of-range namespace index falls back to the raw form rather than throwing, and the static session-based `TryResolve` re-binds a captured `nsu=…` reference against the CURRENT session's namespace table (correcting a post-connect reorder) while still accepting a plain `ns=N;…` form for hand-authored config. `BuildRawPathTable`'s per-entry skip rules (blank RawPath, unreadable/absent `nodeId`) match what the class doc promises ("the driver surfaces such a reference as `BadNodeIdInvalid`") and what `OpcUaClientNamespaceTests.cs` exercises. |
|
||||
| 2 | OtOpcUa conventions | No issues found. Uses the OPC Foundation UA .NET Standard stack's own types (`Opc.Ua.NodeId`, `NamespaceTable`, `ISession`) exactly as intended rather than re-deriving namespace semantics by hand; .NET 10/AnyCPU; no Galaxy/MXAccess surface (not applicable to this module). |
|
||||
| 3 | Concurrency & thread safety | No issues found. `NamespaceMap` is effectively immutable after construction (`_uris`, `_uriToIndex`, `_rawPathToNode` are all populated once in the constructor from freshly-built collections that are never exposed for external mutation), so concurrent reads from multiple threads after a connect are safe with no locking needed. A reconnect is expected to build a fresh `NamespaceMap` instance (out of this module's scope to verify the call site, but nothing in this class assumes otherwise). |
|
||||
| 4 | Error handling & resilience | No issues found. Every public entry point follows the `TryXxx` contract consistently: `TryResolve` (both the instance RawPath overload and the static session-based overload) returns `false` rather than throwing for null/blank/unknown input, and the static overload's `NodeId.Parse` call is wrapped in a `try/catch` that treats any parse failure as a clean `false` — including a concurrently-torn-down session, which the XML doc calls out explicitly as an expected caller scenario on the reconnect path. |
|
||||
| 5 | Security | No issues found. No secrets, no network I/O of its own (session I/O is owned by the consuming driver), no injection surface — this is a pure in-memory table + string-encoding helper. |
|
||||
| 6 | Performance & resource management | No issues found. `Count`/`UriForIndex`/`IndexForUri`/`TryResolve`/`TryGetBinding` are all O(1) dictionary/array lookups; `FromSession`/`FromTable` do one O(n) pass over the namespace table and one O(m) pass over the authored raw tags, both proportional to input size with no redundant work. No `IDisposable` state to manage (nothing here owns a session, socket, or other unmanaged resource). |
|
||||
| 7 | Design-document adherence | No issues found. The "why this type sits in `.Session` and not `.Contracts`" remark accurately describes the actual dependency shape (`.Contracts` stays free of the `OPCFoundation.NetStandard.Opc.Ua.Client` package; the blob read (`OpcUaClientTagDefinitionFactory.TryReadNodeId`) stays in `.Contracts` as the one authority the deploy-time gate and this table share) — confirmed by reading the referenced `.Contracts` factory and its call site here (`BuildRawPathTable`, line 134). |
|
||||
| 8 | Code organization & conventions | No issues found. Single-file, single-responsibility extraction exactly as described in `CLAUDE.md`'s summary of this module ("a small shared-helper project ... so the OpcUaClient Browser and driver could share namespace-mapping logic without the Browser referencing the runtime driver") — confirmed the module has no dependency on `Driver.OpcUaClient` itself, only on `Core.Abstractions` (`RawTagEntry`) and the OPC UA SDK. |
|
||||
| 9 | Testing coverage | No issues found. `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientNamespaceTests.cs` covers the encoding contract (namespace-0 compact form, nonzero-namespace URI encoding, unknown-index fallback, bidirectional index/URI lookup), the v3 RawPath resolution table (successful resolution, unknown/blank RawPath, `WriteIdempotent` threading, an entry with no usable `nodeId` being skipped, legacy PascalCase `NodeId` key tolerance, an empty-raw-tags map), and the null-session negative case of the static session-based `TryResolve`. The success path of the static session-based `TryResolve` (a real, non-null `ISession`) is not exercised in this module's own test file — reasonably so, since constructing a live/fake `ISession` from the SDK is impractical to do in isolation here; it is exercised end-to-end via the consuming driver's integration test suites (`Driver.OpcUaClient.IntegrationTests`, `Driver.OpcUaClient.Browser.IntegrationTests`), which is out of this review's module scope to inspect further. |
|
||||
| 10 | Documentation & comments | No issues found. The class- and member-level XML docs are thorough and verifiably accurate against the implementation (the namespace-reorder rationale, the `nsu=…`/`ns=N;…` dual-form acceptance, the RawPath v3 resolution table's relationship to `TagConfig.nodeId`, and the `.Session`-vs-`.Contracts` placement rationale all match what the code actually does). |
|
||||
|
||||
## Findings
|
||||
|
||||
_No findings recorded — no defects, coverage gaps, or design-adherence issues were found in this module at the reviewed commit. See the checklist notes above for the review evidence per category._
|
||||
@@ -0,0 +1,80 @@
|
||||
# Code Review — Driver.Sql.Browser
|
||||
|
||||
<!-- Per-module findings file. See ../../REVIEW-PROCESS.md for the full process.
|
||||
The base README.md is generated from these files by regen-readme.py — do not
|
||||
edit README.md by hand. -->
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Module | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser` |
|
||||
| Reviewer | Claude Code |
|
||||
| Review date | 2026-08-10 |
|
||||
| Commit reviewed | `b990adae` |
|
||||
| Status | Reviewed |
|
||||
| Open findings | 3 |
|
||||
|
||||
## Checklist coverage
|
||||
|
||||
| # | Category | Result |
|
||||
|---|---|---|
|
||||
| 1 | Correctness & logic bugs | 1 finding (001). The NodeId codec is sound — kind-prefixed arity, escaped separators, a dangling escape rejected rather than truncated — and the ordinal column match on attribute fetch is right for a case-sensitive collation |
|
||||
| 2 | OtOpcUa conventions | No issues found — `DriverType` reads `DriverTypeNames.Sql`, the session is owned by the AdminUI `BrowseSessionRegistry` unchanged, and the project's no-runtime-driver layering (Gitea #526) holds and is guarded by `DriverBrowserLayeringTests` |
|
||||
| 3 | Concurrency & thread safety | 1 finding (002). The single-permit gate correctly serialises catalog calls over one ADO.NET connection, and the disposed check inside the gate wait is the right place for it |
|
||||
| 4 | Error handling & resilience | No issues found — a missing table yields an empty level rather than an error, a column terminal expand is a no-op, and the open path disposes the connection on every failure while the session owns it on success |
|
||||
| 5 | Security | 1 finding (003). No name from a NodeId ever reaches a command text (schema/table are bound parameters, `QuoteIdentifier` is deliberately unused here), and the connection-string redaction on the open path is live-probed against the SqlClient parser's echo behaviour |
|
||||
| 6 | Performance & resource management | No issues found. `AttributesAsync` re-reads the table's columns per call, which is one bounded catalog query per side-panel open and is what keeps the timestamp candidate consistent with the rendered tree |
|
||||
| 7 | Design-document adherence | No issues found — the #552 decision (candidacy by declared TYPE, tie-break first-in-catalog-order, absent key rather than blank) is implemented exactly as `docs/drivers/Sql.md` and `SqlBrowseAddressFields` describe it |
|
||||
| 8 | Code organization & conventions | No issues found |
|
||||
| 9 | Testing coverage | No issues found — 79 tests including the four `topByTimestamp` address-field cases (present, chosen-first, absent, and the browsed-column-is-the-timestamp case) and a concurrency-probing connection |
|
||||
| 10 | Documentation & comments | No issues found |
|
||||
|
||||
## Findings
|
||||
|
||||
### Driver.Sql.Browser-001
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | Low |
|
||||
| Category | Correctness & logic bugs |
|
||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseSession.cs:290` |
|
||||
| Status | Open |
|
||||
|
||||
**Description:** `PreferredTimestampColumn` nominates the first column whose dialect-mapped type is `DriverDataType.DateTime`. That set includes **date-only** types: `SqlServerDialect.MapColumnType` maps `"date"` to DateTime, and so does `PostgresDialect` for `date`. A table whose first date/time column is a `date` therefore commits `rowSelector.topByTimestamp = <date column>`, and the resulting wide-row query is `ORDER BY <date> DESC` limited to one row — which ties for every row written on the same day, so "the newest row" becomes storage order among the ties. The reader cannot warn about it either, because the server has already discarded the other rows (see Driver.Sql-008). The choice is a visible, editable default, so the impact is bounded to a tag that silently binds to an arbitrary row of the latest day until someone notices.
|
||||
|
||||
**Recommendation:** Prefer a time-bearing type when the table has one — i.e. rank candidates and fall back to a date-only column only when nothing better exists — or drop date-only types from candidacy entirely, which makes such a table "not bindable by browse" and routes it through the existing `DescribeUncommittableLeaf` message. Either way state the choice in `docs/drivers/Sql.md`, which currently describes candidacy as simply "date/time-typed".
|
||||
|
||||
**Resolution:** _(open)_
|
||||
|
||||
---
|
||||
|
||||
### Driver.Sql.Browser-002
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | Low |
|
||||
| Category | Concurrency & thread safety |
|
||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseSession.cs:204`, `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseSession.cs:325` |
|
||||
| Status | Open |
|
||||
|
||||
**Description:** Two small races around the registry reaper. (1) `DisposeAsync` disposes `_gate` unconditionally; a call already blocked in `_gate.WaitAsync` then throws `ObjectDisposedException` from inside the semaphore rather than the guarded `ObjectDisposedException.ThrowIf(_disposed, this)` the class raises everywhere else — the release side is defended against exactly this (`catch (ObjectDisposedException)` in the `finally`) but the wait side is not. (2) `LastUsedUtc` advances only after a call completes, so a slow catalog query (the AdminUI allows 20 s) is invisible to the TTL reaper for its whole duration; a session that idles up to the edge of the TTL and is then given one long expand can be disposed mid-call, tearing down the connection under an open reader.
|
||||
|
||||
**Recommendation:** Stamp `LastUsedUtc` on entry as well as on completion, and either leave `_gate` undisposed (the pattern `SqlPollReader` uses and documents) or drain it before disposing. Both are one-line changes; neither is observable to the picker today, which is why this is Low.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
|
||||
---
|
||||
|
||||
### Driver.Sql.Browser-003
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | Low |
|
||||
| Category | Security |
|
||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlDriverBrowser.cs:262` |
|
||||
| Status | Open |
|
||||
|
||||
**Description:** `OpenAsync` accepts an ad-hoc `connectionString` literal from the browse form and dials it verbatim. That is an unrestricted outbound-connect primitive on the AdminUI host: an arbitrary host and port, plus whatever the provider's keyword set allows (`Microsoft.Data.SqlClient` alone offers `AttachDbFilename`, `Failover Partner` and `Enlist`). The mitigations in place are real and deliberate — the literal is never persisted (`SqlDriverConfigDto` has no such property, and the picker composes a session-only document), a paste alongside a `connectionStringRef` is logged, `NodeEdit.razor` warns against pasting one into a saved DriverConfig, and every open-path failure is redacted — but the capability itself is bounded only by whichever AdminUI policy guards the browse endpoint.
|
||||
|
||||
**Recommendation:** No code change proposed — the surface is reached only from `/raw`, which carries `[Authorize(Policy = AdminUiPolicies.ConfigEditor)]` (Administrator or Designer), so it is already at the right authorization level. Record the trust boundary where a reviewer will see it (`docs/security.md`, alongside the other AdminUI-authenticated capabilities): a Designer can make the AdminUI host open an arbitrary outbound database connection, which is a stronger capability than "browse a configured database" reads as.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
@@ -0,0 +1,63 @@
|
||||
# Code Review — Driver.Sql.Contracts
|
||||
|
||||
<!-- Per-module findings file. See ../../REVIEW-PROCESS.md for the full process.
|
||||
The base README.md is generated from these files by regen-readme.py — do not
|
||||
edit README.md by hand. -->
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Module | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts` |
|
||||
| Reviewer | Claude Code |
|
||||
| Review date | 2026-08-10 |
|
||||
| Commit reviewed | `b990adae` |
|
||||
| Status | Reviewed |
|
||||
| Open findings | 2 |
|
||||
|
||||
## Checklist coverage
|
||||
|
||||
| # | Category | Result |
|
||||
|---|---|---|
|
||||
| 1 | Correctness & logic bugs | 2 findings (001, 002) |
|
||||
| 2 | OtOpcUa conventions | No issues found — provider-free by construction (the only `ProjectReference` is `Core.Abstractions`), enum-typed DTO fields with the converter applied by the factory rather than by an attribute, `*TagDefinitionFactory.Inspect` named for the reflection-discovered deploy-gate seam |
|
||||
| 3 | Concurrency & thread safety | No issues found — every type here is a pure function or an immutable record |
|
||||
| 4 | Error handling & resilience | No issues found — `TryParse` returns false rather than throwing for every refusal, and `Inspect` is total (the backstop warning covers a refusal none of its branches names) |
|
||||
| 5 | Security | No issues found. `SqlTagDefinition`'s doc states the rule the rest of the driver enforces — value fields are bound, identifier fields are catalog-validated and dialect-quoted — and the parser deliberately does not sanitise either kind, which is correct: sanitising a value corrupts data and sanitising an identifier masks the authoring error the gate reports properly |
|
||||
| 6 | Performance & resource management | No issues found — one `JsonDocument` per blob, disposed |
|
||||
| 7 | Design-document adherence | No issues found. The accept-gate-first `Inspect` (run the runtime parser, return empty on success, only then explain) is the #549–#559 arc's rule implemented structurally rather than promised, and the strict enum reads are the deliberate R2-11 behaviour |
|
||||
| 8 | Code organization & conventions | No issues found |
|
||||
| 9 | Testing coverage | No issues found — the parser, the strict-enum rejections and the injection-shaped `keyValue` are covered from `Driver.Sql.Tests`, and `Inspect` is covered by the AdminUI parity corpus |
|
||||
| 10 | Documentation & comments | No issues found |
|
||||
|
||||
## Findings
|
||||
|
||||
### Driver.Sql.Contracts-001
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | Low |
|
||||
| Category | Correctness & logic bugs |
|
||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlEquipmentTagParser.cs:65`, `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlTagDefinitionFactory.cs:132` |
|
||||
| Status | Open |
|
||||
|
||||
**Description:** The two value-bearing fields are read asymmetrically and nothing documents it. `whereValue` goes through `ReadScalar`, which accepts a JSON string, number or boolean and captures the raw token — the design doc's own wide-row example authors it as the number `7`. `keyValue` goes through `ReadString`, so a JSON number is read as null and the tag is refused outright. Both end up bound as a `DbParameter` and both are matched back as text (`IndexByKey` stringifies the key cell), so there is no downstream reason for the difference. The refusal is also mis-explained: `Inspect`'s branch is `ReadString(root, "keyValue") is null`, so a numerically-authored key draws "Sql KeyValue tag has no 'keyValue'" — telling the operator the field is missing when it is right there in the blob.
|
||||
|
||||
**Recommendation:** Read `keyValue` with the same `ReadScalar` the row selector uses (the value is bound either way), and mirror the change in `Inspect` so the accept gate and its explanation stay the same parse. If the string-only rule is intended, say so in the rejection text and in `docs/drivers/Sql.md` instead — the current wording is the failure mode the #549 arc calls out, an authoring surface disagreeing with the runtime about *why*.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
|
||||
---
|
||||
|
||||
### Driver.Sql.Contracts-002
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | Low |
|
||||
| Category | Correctness & logic bugs |
|
||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlEquipmentTagParser.cs:50` |
|
||||
| Status | Open |
|
||||
|
||||
**Description:** Cross-module, and reachable through every driver rather than only this one. The strict enum read is `TagConfigJson.TryReadEnumStrict`, which classifies via `Enum.TryParse(text, ignoreCase: true, …)` — and that method accepts a **numeric string**, returning `true` for a value that is not a declared member. So `"type": "99"` reads as `JsonEnumRead.Valid`, the parser accepts the tag, and `SqlTagDefinition.DeclaredType` carries `(DriverDataType)99`: `DiscoverAsync` materialises a variable whose declared type is undefined, and `SqlPollReader.TryCoerce` falls through its switch to the `String` arm, so the node reads a stringified cell under a data type nothing can map. `"model": "7"` is harmless here only by luck — the planner's `default` arm rejects it. Nothing in the module can fix this alone, because the accept/reject authority is deliberately the shared helper.
|
||||
|
||||
**Recommendation:** Add an `Enum.IsDefined` check to `TagConfigJson.ClassifyEnumText` so a numeric spelling is classified `Invalid` (and therefore refused by the strict read and named by `DescribeInvalidEnum`) — one change covering all six parsers and both the runtime and authoring doors. Cross-module, so recorded rather than fixed here; verify the parity corpus in `AdminUI.Tests` after the change, since it exercises every driver's `Inspect`.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
@@ -0,0 +1,80 @@
|
||||
# Code Review — Driver.Sql.Dialect
|
||||
|
||||
<!-- Per-module findings file. See ../../REVIEW-PROCESS.md for the full process.
|
||||
The base README.md is generated from these files by regen-readme.py — do not
|
||||
edit README.md by hand. -->
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Module | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect` |
|
||||
| Reviewer | Claude Code |
|
||||
| Review date | 2026-08-10 |
|
||||
| Commit reviewed | `b990adae` |
|
||||
| Status | Reviewed |
|
||||
| Open findings | 3 |
|
||||
|
||||
## Checklist coverage
|
||||
|
||||
| # | Category | Result |
|
||||
|---|---|---|
|
||||
| 1 | Correctness & logic bugs | 1 finding (001). The three implementations agree member-for-member on the contract, and the two places a dialect must diverge — ODP.NET's marker/bind-name split and `BindByName` — are modelled explicitly rather than derived |
|
||||
| 2 | OtOpcUa conventions | No issues found. The assembly is `.Dialect` while the namespace stays `ZB.MOM.WW.OtOpcUa.Driver.Sql`, which is the documented `Driver.Mqtt.Transport` precedent — the split is a build-graph concern, and the csproj says so |
|
||||
| 3 | Concurrency & thread safety | No issues found — every dialect is stateless and immutable, which is what lets one instance be shared by the poll reader and a browse session |
|
||||
| 4 | Error handling & resilience | No issues found. `MapColumnType` never throws and falls back to `String`; `ConfigureCommand` tolerates a foreign `DbCommand` rather than casting blind |
|
||||
| 5 | Security | No issues found. `QuoteIdentifier` escapes the one metacharacter that can terminate a quoted identifier on each backend, rejects control and Unicode-format characters (the Trojan-Source class), and never echoes the offending value; the catalog SQL binds every runtime value as a parameter |
|
||||
| 6 | Performance & resource management | No issues found — the catalog statements are bounded by their `WHERE` clauses and hold no state |
|
||||
| 7 | Design-document adherence | No issues found — the constructed set (SqlServer, Postgres, Oracle) matches `docs/drivers/Sql.md` and the reserved `MySql`/`Odbc` names fail loudly at every construction site |
|
||||
| 8 | Code organization & conventions | 1 finding (002) — a member the consumer needs and the seam does not carry |
|
||||
| 9 | Testing coverage | No issues found for the offline surface (`SqlServerDialectTests`, `PostgresDialectTests`, `OracleDialectTests` pin quoting, markers, row-limit ends and the type maps). The catalog SQL itself is only ever parsed by a real server, which is what the Docker `*.IntegrationTests` exist for — the `p_` bind prefix defect that shipped was invisible offline for exactly this reason |
|
||||
| 10 | Documentation & comments | 1 finding (003) |
|
||||
|
||||
## Findings
|
||||
|
||||
### Driver.Sql.Dialect-001
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | Low |
|
||||
| Category | Correctness & logic bugs |
|
||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/PostgresDialect.cs:178`, `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/OracleDialect.cs:212` |
|
||||
| Status | Open |
|
||||
|
||||
**Description:** Both identifier ceilings are enforced as `ident.Length`, i.e. UTF-16 code units, but the server limits they stand in for are **byte** limits: PostgreSQL's `NAMEDATALEN - 1` is 63 bytes and Oracle 12.2's is 128 bytes, both measured in the database's encoding (UTF-8 in any modern estate). A non-ASCII identifier therefore passes this check and is still truncated (Postgres) or rejected (Oracle) server-side — 40 Cyrillic characters are 80 UTF-8 bytes. Postgres' truncation is the failure this check documents itself as existing to prevent: "the server silently truncates anything longer, so a 70-character authored name would resolve to a different object than it spells". The residual risk is small because the catalog gate substitutes catalog spellings, and a name that came back from the catalog is by construction within the server's own limit — so this bites only the "future caller that has no catalog to check against" the same doc contemplates.
|
||||
|
||||
**Recommendation:** Measure the limit the way the server does — `Encoding.UTF8.GetByteCount(ident)` — for the two byte-limited dialects, and note in the remarks that T-SQL's `sysname` is genuinely a 128-*character* limit so `SqlServerDialect` is correct as written.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
|
||||
---
|
||||
|
||||
### Driver.Sql.Dialect-002
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | Low |
|
||||
| Category | Code organization & conventions |
|
||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ISqlDialect.cs:29` |
|
||||
| Status | Open |
|
||||
|
||||
**Description:** The seam models identifier quoting, catalog SQL, row-limit syntax, parameter spelling and command setup — everything that differs per backend *except* the per-command bind-parameter ceiling, which also differs per backend (2100 for `Microsoft.Data.SqlClient`, 1000 entries in an Oracle `IN` expression list, 65535 for Npgsql). Its absence is why `SqlGroupPlanner` cannot chunk a key-value `IN` list and a large EAV group fails permanently at poll time (Driver.Sql-002). The seam's own summary is the argument for putting it here: a call site that guesses a provider limit it does not own is the exact defect the interface exists to prevent.
|
||||
|
||||
**Recommendation:** Add a `MaxBindParameters` (or `MaxInListEntries`) member with each dialect stating its own value, and have the planner emit several plans per group key above it. Recorded rather than fixed because the consumer-side change is a planner restructure, not a seam edit — see Driver.Sql-002.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
|
||||
---
|
||||
|
||||
### Driver.Sql.Dialect-003
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | Low |
|
||||
| Category | Documentation & comments |
|
||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/OracleDialect.cs:135` |
|
||||
| Status | Open |
|
||||
|
||||
**Description:** `OracleDialect.ListSchemasSql` reads `ALL_USERS`, and its remarks justify the divergence from the other two dialects (which list only schemas that own a visible relation) purely from the catalog gate's point of view: "harmless here — the list is used to resolve an authored schema name … a schema holding no tables simply resolves and then matches no relation". That is true of the gate and false of the other consumer: `SqlBrowseSession.RootAsync` renders the same query as the browse tree's root level, so on an Oracle estate the operator is offered every database *user* as an expandable schema — hundreds of them on a real installation, nearly all empty, with no way to tell which hold anything. The two consumers of one query have different needs and only one is considered.
|
||||
|
||||
**Recommendation:** Either restrict the query to owners of a visible relation (`SELECT DISTINCT owner FROM ALL_TABLES UNION SELECT DISTINCT owner FROM ALL_VIEWS`), matching what the other two dialects list and what the browse tree wants, or state the browse consequence in the remarks so it is a known trade rather than an oversight. Note the change would also narrow the gate, which is a behaviour change for a tag naming an empty schema — currently it resolves and then fails at the table level; afterwards it would fail at the schema level with a different message.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
@@ -0,0 +1,210 @@
|
||||
# Code Review — Driver.Sql
|
||||
|
||||
<!-- Per-module findings file. See ../../REVIEW-PROCESS.md for the full process.
|
||||
The base README.md is generated from these files by regen-readme.py — do not
|
||||
edit README.md by hand. -->
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Module | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql` |
|
||||
| Reviewer | Claude Code |
|
||||
| Review date | 2026-08-10 |
|
||||
| Commit reviewed | `b990adae` |
|
||||
| Status | Reviewed |
|
||||
| Open findings | 7 |
|
||||
|
||||
## Checklist coverage
|
||||
|
||||
| # | Category | Result |
|
||||
|---|---|---|
|
||||
| 1 | Correctness & logic bugs | 3 findings (001 fixed, 005, 006) |
|
||||
| 2 | OtOpcUa conventions | No issues found — read-only capability set, `DriverTypeNames.Sql` chained off the shared constant, logging through `ILogger` only, xUnit + Shouldly suites, no authorization decisions in driver code |
|
||||
| 3 | Concurrency & thread safety | 1 finding (010). The atomic snapshot swap of the authored/polled tables, the `_gate`-bounded group concurrency and the slot-owned-by-the-work-task discipline are sound |
|
||||
| 4 | Error handling & resilience | 2 findings (002, 004) |
|
||||
| 5 | Security | 1 finding (009). The injection boundary itself is sound — see the note under Findings |
|
||||
| 6 | Performance & resource management | No issues found — one connection per group bounded by `maxConcurrentGroups`, rows materialised before the reader is disposed, abandoned work detached and self-disposing |
|
||||
| 7 | Design-document adherence | 1 finding (003) — the shipped getting-started guide disagreed with the parser |
|
||||
| 8 | Code organization & conventions | 1 finding (007) |
|
||||
| 9 | Testing coverage | No issues found. 365 tests across parser/planner/reader/gate/probe/factory over a real-SQLite poll fixture, with a colon-marker dialect as the provider-assumption control and an injection regression suite; the Postgres/Oracle/SQL Server/blackhole paths are covered by the Docker `*.IntegrationTests` |
|
||||
| 10 | Documentation & comments | 2 findings (003 fixed, 008) |
|
||||
|
||||
## Findings
|
||||
|
||||
Security note (category 5), recorded here rather than as a finding because it is the module working as
|
||||
designed: every authored *value* (`keyValue`, `whereValue`) reaches the database as a `DbParameter`, and
|
||||
every authored *identifier* is resolved against the live catalog by `SqlCatalogGate` and **replaced with the
|
||||
catalog's own spelling** before `ISqlDialect.QuoteIdentifier` sees it, so the identifier text in an emitted
|
||||
statement is a string the server itself returned. `SqlPollReader` resolves only through `_polledByRawPath`,
|
||||
which is populated exclusively by the gate and cleared by `BuildTagTable`, so there is no path from an
|
||||
unvalidated identifier to a command text. The resolved connection string is passed to the provider and to
|
||||
nothing else — every log line, health message and host status carries `Endpoint`'s credential-free
|
||||
`server/database` rendering, and exception *messages* are reduced to the exception type on every operator
|
||||
surface.
|
||||
|
||||
### Driver.Sql-001
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | Medium |
|
||||
| Category | Correctness & logic bugs |
|
||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlCatalogLoader.cs:139` |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `ReadDefaultSchemaAsync`'s own remarks documented a fallback — "falling back to the single visible schema keeps the common one-schema database working" — that the method did not implement: a null/blank answer returned `string.Empty`. The default schema is then the empty string, `SqlCatalog.TryResolveSchema(null)` hands `""` to `TryResolveTable`, the loader issues the tables query for schema `''`, it matches nothing, and every unqualified authored table on the driver is rejected by the catalog gate and reads `BadNodeIdUnknown` — a whole-driver outage against a healthy database with every tag correctly authored. The blank answer is reachable in production: a SQL Server login with no `DEFAULT_SCHEMA` mapped, and a Postgres connection whose `search_path` resolves to nothing, both answer null. No test covered it, because the test dialect's `DefaultSchemaSql` is the literal `SELECT 'main'`.
|
||||
|
||||
**Recommendation:** Implement the documented fallback — when the dialect's answer is null/blank use the sole visible schema and only that one; with several visible schemas there is no defensible guess and the empty string must continue to resolve nothing.
|
||||
|
||||
**Resolution:** Fixed on this review branch (`review/sql` `69ad376f`, 2026-08-10). `LoadAsync` now passes the already-loaded schema list into `ReadDefaultSchemaAsync`, which falls back to `schemas[0]` when exactly one schema is visible. TDD: `SqlCatalogDefaultSchemaTests` was written red (the unqualified tag read `BadNodeIdUnknown`) and carries a falsifiability control proving the ambiguous multi-schema case still rejects rather than guessing.
|
||||
|
||||
---
|
||||
|
||||
### Driver.Sql-002
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | Medium |
|
||||
| Category | Error handling & resilience |
|
||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlGroupPlanner.cs:127` |
|
||||
| Status | Open |
|
||||
|
||||
**Description:** `BuildKeyValuePlan` emits one bind parameter per distinct `keyValue` in a group, with no upper bound and no chunking. Every provider caps this: `Microsoft.Data.SqlClient` allows 2100 parameters per command, and Oracle limits an `IN` expression list to 1000 entries. A key-value (EAV) staging table is precisely the shape that attracts thousands of tags on one table — which is one group by the group-key rule — so above the cap the provider rejects the statement after the connection opened, `QueryAsync` returns `GroupResult.Failed`, and every tag in that group publishes `BadCommunicationError` on every poll, permanently. The failure is also mis-diagnosed: `ObservePollOutcome` classifies `BadCommunicationError` as connection-class, so the driver reports Degraded and the host `Stopped` (see Driver.Sql-005), pointing the operator at the network rather than at a tag count. Nothing upstream caps the tag count per driver or per table, and the log line names the SQL but not the cause.
|
||||
|
||||
**Recommendation:** Chunk the `IN` list. The natural shape is a `MaxBindParameters` member on `ISqlDialect` (see Driver.Sql.Dialect-002) and a planner that emits several plans for one group key when the member count exceeds it — `SqlPollReader.Slice` already maps per `plan.Members`, so several plans sharing a group key need no reader change. Failing that, gate the tag count at deploy time with a message naming the provider limit, rather than letting it surface as a permanent per-poll communication error.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
|
||||
---
|
||||
|
||||
### Driver.Sql-003
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | Medium |
|
||||
| Category | Design-document adherence |
|
||||
| Location | `docs/drivers/Sql.md:55`, `docs/drivers/Sql.md:90` |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** Two claims in the shipped getting-started guide disagreed with the code. (1) Both `WideRow` examples spelled the row selector at the top level (`"whereColumn": …`, `"topByTimestamp": …`), but `SqlEquipmentTagParser.TryParse` reads those keys only from a nested `rowSelector` object — a blob authored from the doc carries no row selector, so the parser refuses the tag, the driver skips it, and it reads `BadNodeIdUnknown` forever. This is the same defect class as Gitea #552, arriving through the documentation rather than through the browse-commit mapper. (2) The "Optional per-tag fields" table documented a `pollIntervalMs` per-tag override; no such field exists anywhere in the tree — `SqlTagDefinition` has no member for it and nothing reads the key, so an operator authoring it silently gets the instance default.
|
||||
|
||||
**Recommendation:** Correct both: nest the selector keys inside `rowSelector` in both examples with a warning that the top-level spelling is refused, and delete the `pollIntervalMs` row, stating where the interval actually comes from.
|
||||
|
||||
**Resolution:** Fixed on this review branch (`review/sql` `69ad376f`, 2026-08-10). Both examples now use the nested `rowSelector` form and carry an explicit warning about the top-level spelling; the `pollIntervalMs` row is replaced by a paragraph naming the subscriber interval / `defaultPollInterval` / `PollGroupEngine.DefaultMinInterval` chain and recording that the field never existed. The scalar/string asymmetry between `whereValue` and `keyValue` (Driver.Sql.Contracts-001) is documented in the same edit.
|
||||
|
||||
---
|
||||
|
||||
### Driver.Sql-004
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | Low |
|
||||
| Category | Error handling & resilience |
|
||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs:651` |
|
||||
| Status | Open |
|
||||
|
||||
**Description:** `HandlePollError` skips the connection class by testing `ex is DbException`, on the stated premise that "a subscribed-read outage surfaces as a DbException the reader threw, which ReadAsync has ALREADY classified". Not every unreachable-database throw is a `DbException`: `SqlPollReader.QueryAsync` raises `InvalidOperationException` when the provider factory returns no connection, and a provider may raise `ArgumentException`/`FormatException` from a malformed connection string on the same open path. For those, `ReadAsync` writes the good endpoint-bearing `LastError` and transitions the host to `Stopped`, rethrows, and the poll engine then routes the same exception here — where `Degrade` overwrites that message with the guidance-free "poll failed: TypeName" and the outage is logged a second time. The comment describes the intended behaviour; the type test does not implement it.
|
||||
|
||||
**Recommendation:** Mark the exception as already-classified at the one place that knows (e.g. `ReadAsync` wrapping the throw, or a per-call flag) rather than inferring it from the exception type, so the reader's richer `LastError` survives.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
|
||||
---
|
||||
|
||||
### Driver.Sql-005
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | Low |
|
||||
| Category | Correctness & logic bugs |
|
||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs:641` |
|
||||
| Status | Open |
|
||||
|
||||
**Description:** `IsConnectionClass` treats `BadCommunicationError` as "the database did not answer", so a poll carrying it reports the endpoint `Stopped`. But that code is minted by `GroupResult.Failed`, which the reader raises only after the connection has opened — its own comment says "the connection opened, so the server is up — this query is what failed (an identifier that is not in the catalog, a lock, a permission)". A single group failing on a lock, a revoked `SELECT`, a schema change or an over-long `IN` list (see Driver.Sql-002) therefore reports the whole database endpoint as down. The two types disagree about what the same status code means.
|
||||
|
||||
**Recommendation:** Either restrict the connection class to `BadTimeout` (a wedged socket is the case the host-state signal is for), or give a post-open query failure its own status so the two classifications cannot drift. Note the host state is currently only consumed through `IHostConnectivityProbe`, which is unwired (Gitea #521) — the operator-visible half today is the `LastError` wording and the Degraded verdict.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
|
||||
---
|
||||
|
||||
### Driver.Sql-006
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | Low |
|
||||
| Category | Correctness & logic bugs |
|
||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs:599` |
|
||||
| Status | Open |
|
||||
|
||||
**Description:** `ObservePollOutcome` advances `LastSuccessfulRead` only when at least one snapshot is Good. Several codes are proof the database answered without being Good: `BadNoData` (the query ran, the row is absent), `Uncertain`/`Bad` (a present row with a NULL cell) and `BadTypeMismatch` (a cell came back and would not coerce). A driver whose every tag reads one of those — a truncated staging table, a key set that was deleted — leaves `LastSuccessfulRead` frozen. That is benign on its own (health is correctly left unchanged, because these are authoring/data facts), but it interacts with the comms-loss watchdog: once anything has degraded the driver, the conjunction "non-Healthy AND `LastSuccessfulRead` frozen for 90 s" (CLAUDE.md, Gitea #527) trips against a database that is answering every poll on time, and the node force-reconnects a healthy connection.
|
||||
|
||||
**Recommendation:** Advance `LastSuccessfulRead` on evidence-of-answer codes, not only on Good — the honest reading of "the database answered" the watchdog was designed around — while leaving the health state classification exactly as it is.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
|
||||
---
|
||||
|
||||
### Driver.Sql-007
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | Low |
|
||||
| Category | Code organization & conventions |
|
||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj:21` |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** The project carried a direct `Microsoft.Data.SqlClient` `PackageReference` that no source file in it uses — every type speaks `System.Data.Common` only, and the concrete provider packages moved behind the `.Dialect` leaf when `ISqlDialect`/`SqlServerDialect` were extracted (Gitea #526). Beyond the dead dependency, it reads as "this project is SQL-Server-specific", which is the assumption the dialect seam exists to prevent.
|
||||
|
||||
**Recommendation:** Drop the reference (the `.Dialect` project reference already supplies it transitively for anything that needs it) and record why the project deliberately carries no provider package.
|
||||
|
||||
**Resolution:** Fixed on this review branch (`review/sql` `69ad376f`, 2026-08-10). Reference removed and replaced with a comment stating the rule; the module's 365 tests still pass.
|
||||
|
||||
---
|
||||
|
||||
### Driver.Sql-008
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | Low |
|
||||
| Category | Documentation & comments |
|
||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlPollReader.cs:556` |
|
||||
| Status | Open |
|
||||
|
||||
**Description:** `SelectWideRow`'s remarks justify giving the `topByTimestamp` form the dialect's single-row limit on the grounds that "there the `ORDER BY` makes 'the first row' a defined answer". That holds only when the ordering column is unique. `ORDER BY ts DESC` plus a one-row limit over a column with ties returns an arbitrary row among the tied ones, and — unlike the where-pair form, whose whole argument for not taking the limit is that `Rows.Count > 1` is the evidence of ambiguity — the reader can never detect it, because the server has already discarded the other rows. The browse-commit default makes the tie shape reachable rather than theoretical: `SqlBrowseSession.PreferredTimestampColumn` will happily nominate a `date`-typed column (see Driver.Sql.Browser-001), which ties for every row written on the same day.
|
||||
|
||||
**Recommendation:** State the tie caveat where the claim is made, and in `docs/drivers/Sql.md` alongside the `topByTimestamp` example: the model requires a selector that identifies one row, and a non-unique ordering column silently does not.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
|
||||
---
|
||||
|
||||
### Driver.Sql-009
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | Low |
|
||||
| Category | Security |
|
||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverProbe.cs:83` |
|
||||
| Status | Open |
|
||||
|
||||
**Description:** The probe surfaces the raw parser text on a malformed config ("Config JSON is invalid: " + `ex.Message`) where `SqlDriverBrowser.ParseDocument` deliberately withholds the identical message "because the malformed text may carry a connection string". The two read the same blob shape: the AdminUI Sql picker composes an ad-hoc document carrying a top-level `connectionString` literal, so a malformed paste is exactly the case that reaches both. `System.Text.Json`'s message quotes the offending token and the JSON path rather than the property value, so the current exposure is small — but the rule is stated absolutely elsewhere in this driver ("never `ex.Message` on an operator surface") and the probe is the one place that breaks it.
|
||||
|
||||
**Recommendation:** Match the browser: report the failure class, not the parser's message.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
|
||||
---
|
||||
|
||||
### Driver.Sql-010
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | Low |
|
||||
| Category | Concurrency & thread safety |
|
||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs:675` |
|
||||
| Status | Open |
|
||||
|
||||
**Description:** Every health transition is a read-modify-write over a `Volatile`-accessed field with no compare-and-swap: `Degrade`, `TouchLastSuccessfulRead` and `SetHealthUnlessFaulted` each `ReadHealth()`, build a new record and `WriteHealth()` it. Concurrent poll loops (one per subscription, plus an ad-hoc `ReadAsync`) can interleave between the read and the write, so a `LastSuccessfulRead` advance can be lost, or a `Faulted` verdict recorded by a concurrent reinitialize can be overwritten by a stale non-Faulted snapshot despite the guard — the guard reads the field before the write rather than atomically with it. Impact is limited to the health surface (a stale timestamp or state until the next poll), which is why this is Low.
|
||||
|
||||
**Recommendation:** Use `Interlocked.CompareExchange` in a small retry loop for the read-modify-write helpers, or funnel health mutation through a lock — the `_probeLock` the host-state transition already uses would do.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
+76
-1
@@ -33,6 +33,8 @@ Each module's `findings.md` is the source of truth; this file is generated from
|
||||
| [Driver.AbLegacy](Driver.AbLegacy/findings.md) | Claude Code | 2026-06-19 | `7286d320` | Reviewed | 0 | 15 |
|
||||
| [Driver.AbLegacy.Cli](Driver.AbLegacy.Cli/findings.md) | Claude Code | 2026-06-19 | `111d6983` | Reviewed | 0 | 8 |
|
||||
| [Driver.AbLegacy.Contracts](Driver.AbLegacy.Contracts/findings.md) | Claude Code | 2026-06-19 | `a19b0f86` | Reviewed | 0 | 4 |
|
||||
| [Driver.Calculation](Driver.Calculation/findings.md) | Claude (automated review agent) | 2026-08-10 | `b990adae` | Reviewed | 1 | 5 |
|
||||
| [Driver.Calculation.Contracts](Driver.Calculation.Contracts/findings.md) | Claude (automated review agent) | 2026-08-10 | `b990adae` | Reviewed | 1 | 1 |
|
||||
| [Driver.Cli.Common](Driver.Cli.Common/findings.md) | Claude Code | 2026-06-19 | `7286d320` | Reviewed | 0 | 10 |
|
||||
| [Driver.FOCAS](Driver.FOCAS/findings.md) | Claude Code | 2026-06-19 | `04e0877b` (re-review; prior `76d35d1`) | Reviewed | 0 | 12 |
|
||||
| [Driver.FOCAS.Cli](Driver.FOCAS.Cli/findings.md) | Claude Code | 2026-06-19 | `7286d320` | Reviewed | 0 | 7 |
|
||||
@@ -40,19 +42,31 @@ Each module's `findings.md` is the source of truth; this file is generated from
|
||||
| [Driver.Galaxy](Driver.Galaxy/findings.md) | Claude Code | 2026-06-19 | `7286d320` | Reviewed | 0 | 19 |
|
||||
| [Driver.Galaxy.Browser](Driver.Galaxy.Browser/findings.md) | Claude Code | 2026-06-19 | `7286d320` | Reviewed | 0 | 4 |
|
||||
| [Driver.Galaxy.Contracts](Driver.Galaxy.Contracts/findings.md) | Claude Code | 2026-06-19 | `7286d320` | Reviewed | 0 | 3 |
|
||||
| [Driver.Historian.Gateway](Driver.Historian.Gateway/findings.md) | Claude Code | 2026-08-10 | `b990adae` | Reviewed | 0 | 3 |
|
||||
| [Driver.Historian.Wonderware](Driver.Historian.Wonderware/findings.md) | Claude Code | 2026-06-19 | `7286d320` | Reviewed | 0 | 12 |
|
||||
| [Driver.Historian.Wonderware.Client](Driver.Historian.Wonderware.Client/findings.md) | Claude Code | 2026-06-19 | `7286d320` | Reviewed | 0 | 13 |
|
||||
| [Driver.Historian.Wonderware.Client.Contracts](Driver.Historian.Wonderware.Client.Contracts/findings.md) | Claude Code | 2026-06-19 | `a19b0f86` | Reviewed | 0 | 4 |
|
||||
| [Driver.MTConnect](Driver.MTConnect/findings.md) | Claude (per-module review) | 2026-08-10 | `b990adae` | Complete | 5 | 8 |
|
||||
| [Driver.MTConnect.Contracts](Driver.MTConnect.Contracts/findings.md) | Claude (per-module review) | 2026-08-10 | `b990adae` | Complete | 0 | 1 |
|
||||
| [Driver.Modbus](Driver.Modbus/findings.md) | Claude Code | 2026-06-19 | `7286d320` | Reviewed | 0 | 14 |
|
||||
| [Driver.Modbus.Addressing](Driver.Modbus.Addressing/findings.md) | Claude Code | 2026-06-19 | `7286d320` | Reviewed | 0 | 11 |
|
||||
| [Driver.Modbus.Cli](Driver.Modbus.Cli/findings.md) | Claude Code | 2026-06-19 | `7286d320` | Reviewed | 0 | 11 |
|
||||
| [Driver.Modbus.Contracts](Driver.Modbus.Contracts/findings.md) | Claude Code | 2026-06-19 | `a19b0f86` | Reviewed | 0 | 4 |
|
||||
| [Driver.Mqtt](Driver.Mqtt/findings.md) | Claude Code | 2026-08-10 | `b990adae` | Reviewed | 2 | 10 |
|
||||
| [Driver.Mqtt.Browser](Driver.Mqtt.Browser/findings.md) | Claude | 2026-08-10 | `b990adae` | Reviewed | 4 | 5 |
|
||||
| [Driver.Mqtt.Contracts](Driver.Mqtt.Contracts/findings.md) | Claude | 2026-08-10 | `b990adae` | Reviewed | 4 | 7 |
|
||||
| [Driver.Mqtt.Transport](Driver.Mqtt.Transport/findings.md) | Claude Code | 2026-08-10 | `b990adae` | Reviewed | 1 | 2 |
|
||||
| [Driver.OpcUaClient](Driver.OpcUaClient/findings.md) | Claude Code | 2026-06-19 | `7286d320` | Reviewed | 0 | 16 |
|
||||
| [Driver.OpcUaClient.Browser](Driver.OpcUaClient.Browser/findings.md) | Claude Code | 2026-06-19 | `7286d320` | Reviewed | 0 | 3 |
|
||||
| [Driver.OpcUaClient.Contracts](Driver.OpcUaClient.Contracts/findings.md) | Claude Code | 2026-06-19 | `a19b0f86` | Reviewed | 0 | 3 |
|
||||
| [Driver.OpcUaClient.Session](Driver.OpcUaClient.Session/findings.md) | Claude (automated review agent) | 2026-08-10 | `b990adae` | Reviewed | 0 | 0 |
|
||||
| [Driver.S7](Driver.S7/findings.md) | Claude Code | 2026-06-19 | `7286d320` | Reviewed | 0 | 16 |
|
||||
| [Driver.S7.Cli](Driver.S7.Cli/findings.md) | Claude Code | 2026-06-19 | `111d6983` | Reviewed | 0 | 12 |
|
||||
| [Driver.S7.Contracts](Driver.S7.Contracts/findings.md) | Claude Code | 2026-06-19 | `a19b0f86` | Reviewed | 0 | 3 |
|
||||
| [Driver.Sql](Driver.Sql/findings.md) | Claude Code | 2026-08-10 | `b990adae` | Reviewed | 7 | 10 |
|
||||
| [Driver.Sql.Browser](Driver.Sql.Browser/findings.md) | Claude Code | 2026-08-10 | `b990adae` | Reviewed | 3 | 3 |
|
||||
| [Driver.Sql.Contracts](Driver.Sql.Contracts/findings.md) | Claude Code | 2026-08-10 | `b990adae` | Reviewed | 2 | 2 |
|
||||
| [Driver.Sql.Dialect](Driver.Sql.Dialect/findings.md) | Claude Code | 2026-08-10 | `b990adae` | Reviewed | 3 | 3 |
|
||||
| [Driver.TwinCAT](Driver.TwinCAT/findings.md) | Claude Code | 2026-06-19 | `04e0877b` | Reviewed | 0 | 17 |
|
||||
| [Driver.TwinCAT.Cli](Driver.TwinCAT.Cli/findings.md) | Claude Code | 2026-06-19 | `111d6983` | Reviewed | 0 | 9 |
|
||||
| [Driver.TwinCAT.Contracts](Driver.TwinCAT.Contracts/findings.md) | Claude Code | 2026-06-19 | `a19b0f86` | Reviewed | 0 | 2 |
|
||||
@@ -66,7 +80,41 @@ Each module's `findings.md` is the source of truth; this file is generated from
|
||||
|
||||
Findings with status `Open` or `In Progress`, ordered by severity.
|
||||
|
||||
_No pending findings._
|
||||
| ID | Severity | Category | Location | Description |
|
||||
|---|---|---|---|---|
|
||||
| Driver.MTConnect-003 | High | Correctness & logic bugs | `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawBrowseCommitMapper.cs:256` (cause) · `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs:3286` (`BuildBinding` precedence) | **Every browse-committed MTConnect tag is coerced to `String` at runtime while its OPC UA node is typed from the probe inference**, so a browsed `SAMPLE` publishes text into a `Double` node. `RawBrowseCommitMapper.MapLeaf` does two things… |
|
||||
| Driver.Calculation-005 | Medium | Concurrency & thread safety | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationDriver.cs:300-314` (`OnDependencyValue`), `:374-410` (`EvaluateTag`); cross-module: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyConsumerMuxAdapter.cs` | `OnDependencyValue` evaluates every change-triggered dependent tag **inline, under `_lock`**, and `EvaluateTag`'s call into `CalculationEvaluator.Evaluate` ultimately runs `TimedScriptEvaluator<...>.RunAsync(context).GetAwaiter().GetResult… |
|
||||
| Driver.MTConnect-004 | Medium | Design-document adherence | `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/MTConnectDriverForm.razor:1` · `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/MTConnectDriverOptions.cs:146` | `IWritable` shipped for this driver on 2026-08-05 (`b29623cf`) behind `Write.Enabled`, and `docs/drivers/MTConnect.md` §"Two independent gates" tells the operator to set `"Write": { "Enabled": true }` in the driver config. **There is no wa… |
|
||||
| Driver.Sql-002 | Medium | Error handling & resilience | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlGroupPlanner.cs:127` | `BuildKeyValuePlan` emits one bind parameter per distinct `keyValue` in a group, with no upper bound and no chunking. Every provider caps this: `Microsoft.Data.SqlClient` allows 2100 parameters per command, and Oracle limits an `IN` expres… |
|
||||
| Driver.Calculation.Contracts-001 | Low | Performance & resource management | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Contracts/CalculationTagDefinitionFactory.cs:107-160` (`Inspect`) | `Inspect` parses `reference` with `JsonDocument.Parse` once directly (to check the root kind and read `changeTriggered`/`timerIntervalMs` for the "present-but-discarded" warnings), and then — when the object parsed cleanly — calls `TryRead… |
|
||||
| Driver.MTConnect-005 | Low | Concurrency & thread safety | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs:2655` · `:689` · `:797` · `:1425` | `_options` is the one piece of shared driver state published by a **plain** field write (`_options = options;` at the `StartCoreAsync` commit point) and read lock-free from other threads — `ReadAsync`, `WriteAsync`, `DiscoverAsync`, `HostN… |
|
||||
| Driver.MTConnect-006 | Low | Correctness & logic bugs | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs:914` | `TryFormatValue` refuses `null` and `Array`, then renders everything else with `Convert.ToString(value, InvariantCulture)`. Its own remark gives the right reason for the array guard — `Convert.ToString` falls back to `object.ToString()` fo… |
|
||||
| Driver.MTConnect-007 | Low | Performance & resource management | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectAgentClient.cs:85` | `MTConnectAgentClient.Dispose()` is a plain check-then-set on a non-volatile `_disposed` field, so two concurrent disposals can both pass the guard. The second then calls `_disposeCts.Cancel()` on an already-disposed `CancellationTokenSour… |
|
||||
| Driver.Mqtt-006 | Low | Concurrency & thread safety | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/SparkplugPrimaryHost.cs:278` | `DisposeAsync` calls `_gate.Dispose()`. `StopAsync` cancels the reconcile loop but does not await it, so a `ReconcileAsync` already in flight can be sitting in `ClaimAsync`/`ReleaseAsync`'s `_gate.WaitAsync(...)`, or on its way to the `fin… |
|
||||
| Driver.Mqtt-007 | Low | Error handling & resilience | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttSubscriptionManager.cs:406-424` (`OnReconnectedAsync`) | When the reconnect path finds live subscribed topics but resolves **zero** filters for them, it logs a warning and returns normally. The type's own contract, stated two paragraphs later, is that a reconnect which re-establishes nothing mus… |
|
||||
| Driver.Mqtt.Browser-002 | Low | Correctness & logic bugs | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttBrowseSession.cs:469` | The plain-mode and Sparkplug-mode paths disagree about what may become a tree label. `ObserveSparkplugBirth` runs every group/edge-node/device/metric identifier through `IsUsableLabel`, whose doc explains the reason: "a metric name is arbi… |
|
||||
| Driver.Mqtt.Browser-003 | Low | Concurrency & thread safety | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttBrowseSession.cs:685` | `GetOrCreate` reads `_nodeCount` and compares it to `_nodeCap`, then increments after a successful `GetOrAdd`. The read and the increment are not atomic with respect to each other, so N threads can each observe `_nodeCount == _nodeCap - 1`… |
|
||||
| Driver.Mqtt.Browser-004 | Low | Correctness & logic bugs | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttBrowseSession.cs:1079` | `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 Sparkplug mode two siblings *can* share a `Segment`: a device folder `Plant1/E… |
|
||||
| Driver.Mqtt.Browser-005 | Low | Performance & resource management | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttBrowseSession.cs:59`, `:81` | 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` (up to `MaxTopicChars` = 1,024 chars), its `Segment` (up to 256) and a snippet… |
|
||||
| Driver.Mqtt.Contracts-004 | Low | Documentation & comments | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagDefinitionFactory.cs:124`, `:234`, `:476` | `retainSeed` is the one behavioural key still read leniently. `ReadBoolOrDefault` returns the default for any non-boolean JSON value, so `"retainSeed": "false"` (a string, which a CSV import or a hand-edited blob produces naturally) silent… |
|
||||
| Driver.Mqtt.Contracts-005 | Low | Testing coverage | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttDriverOptions.cs:25`, `:71`, `:75`, `:79`, `:83`, `:99`, `:229` | `MqttDriverOptions` / `MqttPlainOptions` carry seven `System.ComponentModel.DataAnnotations` `[Range]` attributes and **nothing ever runs them**. Grepping `Validator.TryValidateObject` / `ValidationContext` across all four MQTT projects re… |
|
||||
| Driver.Mqtt.Contracts-006 | Low | Correctness & logic bugs | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttWriteEncoder.cs:281` | Two edge cases in the `DriverDataType.DateTime` write arm. First, `(ulong)…ToUnixTimeMilliseconds()` on a pre-1970 value wraps a negative `long` into a very large `ulong`, so writing `1969-12-31` publishes a timestamp roughly 584 million y… |
|
||||
| Driver.Mqtt.Contracts-007 | Low | Error handling & resilience | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/Sparkplug/SparkplugTopic.cs:202`, `:232` | `SparkplugTopic` validates asymmetrically. `TryParse` enforces `IsValidSegment` (non-empty, no `+`/`#`) on every segment it reads, but `Format` and `FormatState` enforce only `ArgumentException.ThrowIfNullOrEmpty` on `groupId`/`edgeNodeId`… |
|
||||
| Driver.Mqtt.Transport-002 | Low | Correctness & logic bugs | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Transport/MqttClientOptionsFactory.cs:97-100` | Credentials are attached only when `Username` is non-empty: `if (!string.IsNullOrEmpty(options.Username)) builder.WithCredentials(options.Username, options.Password ?? "")`. A config carrying a `Password` and **no** `Username` therefore co… |
|
||||
| Driver.Sql-004 | Low | Error handling & resilience | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs:651` | `HandlePollError` skips the connection class by testing `ex is DbException`, on the stated premise that "a subscribed-read outage surfaces as a DbException the reader threw, which ReadAsync has ALREADY classified". Not every unreachable-da… |
|
||||
| Driver.Sql-005 | Low | Correctness & logic bugs | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs:641` | `IsConnectionClass` treats `BadCommunicationError` as "the database did not answer", so a poll carrying it reports the endpoint `Stopped`. But that code is minted by `GroupResult.Failed`, which the reader raises only after the connection h… |
|
||||
| Driver.Sql-006 | Low | Correctness & logic bugs | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs:599` | `ObservePollOutcome` advances `LastSuccessfulRead` only when at least one snapshot is Good. Several codes are proof the database answered without being Good: `BadNoData` (the query ran, the row is absent), `Uncertain`/`Bad` (a present row… |
|
||||
| Driver.Sql-008 | Low | Documentation & comments | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlPollReader.cs:556` | `SelectWideRow`'s remarks justify giving the `topByTimestamp` form the dialect's single-row limit on the grounds that "there the `ORDER BY` makes 'the first row' a defined answer". That holds only when the ordering column is unique. `ORDER… |
|
||||
| Driver.Sql-009 | Low | Security | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverProbe.cs:83` | The probe surfaces the raw parser text on a malformed config ("Config JSON is invalid: " + `ex.Message`) where `SqlDriverBrowser.ParseDocument` deliberately withholds the identical message "because the malformed text may carry a connection… |
|
||||
| Driver.Sql-010 | Low | Concurrency & thread safety | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs:675` | Every health transition is a read-modify-write over a `Volatile`-accessed field with no compare-and-swap: `Degrade`, `TouchLastSuccessfulRead` and `SetHealthUnlessFaulted` each `ReadHealth()`, build a new record and `WriteHealth()` it. Con… |
|
||||
| Driver.Sql.Browser-001 | Low | Correctness & logic bugs | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseSession.cs:290` | `PreferredTimestampColumn` nominates the first column whose dialect-mapped type is `DriverDataType.DateTime`. That set includes **date-only** types: `SqlServerDialect.MapColumnType` maps `"date"` to DateTime, and so does `PostgresDialect`… |
|
||||
| Driver.Sql.Browser-002 | Low | Concurrency & thread safety | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseSession.cs:204`, `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseSession.cs:325` | Two small races around the registry reaper. (1) `DisposeAsync` disposes `_gate` unconditionally; a call already blocked in `_gate.WaitAsync` then throws `ObjectDisposedException` from inside the semaphore rather than the guarded `ObjectDis… |
|
||||
| Driver.Sql.Browser-003 | Low | Security | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlDriverBrowser.cs:262` | `OpenAsync` accepts an ad-hoc `connectionString` literal from the browse form and dials it verbatim. That is an unrestricted outbound-connect primitive on the AdminUI host: an arbitrary host and port, plus whatever the provider's keyword s… |
|
||||
| Driver.Sql.Contracts-001 | Low | Correctness & logic bugs | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlEquipmentTagParser.cs:65`, `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlTagDefinitionFactory.cs:132` | The two value-bearing fields are read asymmetrically and nothing documents it. `whereValue` goes through `ReadScalar`, which accepts a JSON string, number or boolean and captures the raw token — the design doc's own wide-row example author… |
|
||||
| Driver.Sql.Contracts-002 | Low | Correctness & logic bugs | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlEquipmentTagParser.cs:50` | Cross-module, and reachable through every driver rather than only this one. The strict enum read is `TagConfigJson.TryReadEnumStrict`, which classifies via `Enum.TryParse(text, ignoreCase: true, …)` — and that method accepts a **numeric st… |
|
||||
| Driver.Sql.Dialect-001 | Low | Correctness & logic bugs | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/PostgresDialect.cs:178`, `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/OracleDialect.cs:212` | Both identifier ceilings are enforced as `ident.Length`, i.e. UTF-16 code units, but the server limits they stand in for are **byte** limits: PostgreSQL's `NAMEDATALEN - 1` is 63 bytes and Oracle 12.2's is 128 bytes, both measured in the d… |
|
||||
| Driver.Sql.Dialect-002 | Low | Code organization & conventions | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ISqlDialect.cs:29` | The seam models identifier quoting, catalog SQL, row-limit syntax, parameter spelling and command setup — everything that differs per backend *except* the per-command bind-parameter ceiling, which also differs per backend (2100 for `Micros… |
|
||||
| Driver.Sql.Dialect-003 | Low | Documentation & comments | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/OracleDialect.cs:135` | `OracleDialect.ListSchemasSql` reads `ALL_USERS`, and its remarks justify the divergence from the other two dialects (which list only schemas that own a visible relation) purely from the catalog gate's point of view: "harmless here — the l… |
|
||||
|
||||
## Closed findings
|
||||
|
||||
@@ -104,6 +152,7 @@ Findings with status `Resolved`, `Won't Fix`, or `Deferred`.
|
||||
| Driver.AbCip-008 | High | Resolved | Concurrency & thread safety | `AbCipDriver.cs:144-152`, `AbCipDriver.cs:169-183`, `AbCipDriver.cs:235-281` |
|
||||
| Driver.AbLegacy-001 | High | Resolved | Correctness & logic bugs | `AbLegacyAddress.cs:54`, `AbLegacyDriver.cs:368-374` |
|
||||
| Driver.AbLegacy-006 | High | Resolved | Concurrency & thread safety | `AbLegacyDriver.cs:107-158`, `AbLegacyDriver.cs:162-234`, `LibplctagLegacyTagRuntime.cs` |
|
||||
| Driver.Calculation-002 | High | Resolved | Correctness & logic bugs | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationDriver.cs:374-410` (pre-fix `EvaluateTag`) |
|
||||
| Driver.Cli.Common-001 | High | Resolved | Correctness & logic bugs | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/SnapshotFormatter.cs:106-119` |
|
||||
| Driver.Cli.Common-007 | High | Resolved | Correctness & logic bugs | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/SnapshotFormatter.cs:129` |
|
||||
| Driver.FOCAS-001 | High | Resolved | Correctness & logic bugs | `FocasDriverFactoryExtensions.cs:54-86`, `FocasDriverFactoryExtensions.cs:132-140` |
|
||||
@@ -111,11 +160,15 @@ Findings with status `Resolved`, `Won't Fix`, or `Deferred`.
|
||||
| Driver.Galaxy-002 | High | Resolved | Correctness & logic bugs | `Browse/DataTypeMap.cs:13`, `Runtime/MxValueDecoder.cs:9` |
|
||||
| Driver.Galaxy-008 | High | Resolved | Error handling & resilience | `GalaxyDriver.cs:264-276`, `Runtime/EventPump.cs:97-103` |
|
||||
| Driver.Galaxy.Browser-001 | High | Resolved | Correctness & logic bugs | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/GalaxyBrowseSession.cs:152` |
|
||||
| Driver.Historian.Gateway-001 | High | Resolved | Correctness & logic bugs | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Mapping/AlarmEventMapper.cs:39` (write side) and `Mapping/EventMapper.cs:62` (read side) |
|
||||
| Driver.Historian.Wonderware-001 | High | Resolved | Correctness and logic bugs | `Backend/SdkAlarmHistorianWriteBackend.cs:68`, `Backend/AahClientManagedAlarmEventWriter.cs:82-103` |
|
||||
| Driver.Historian.Wonderware.Client-001 | High | Resolved | Correctness & logic bugs | `WonderwareHistorianClient.cs:98-113` |
|
||||
| Driver.Historian.Wonderware.Client.Contracts-001 | High | Resolved | Security | `WonderwareHistorianClientOptions.cs:24-30` |
|
||||
| Driver.Modbus-001 | High | Resolved | Concurrency & thread safety | `ModbusDriver.cs:92,99-122` |
|
||||
| Driver.Modbus.Addressing-001 | High | Resolved | Correctness & logic bugs | `ModbusAddressParser.cs:230-235`, `DirectLogicAddress.cs:66-73` |
|
||||
| Driver.Mqtt-001 | High | Resolved | Correctness & logic bugs | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttSubscriptionManager.cs:193` (`AttachTo`), `:328` (`SubscribeAsync`'s pending set) |
|
||||
| Driver.Mqtt-002 | High | Resolved | Concurrency & thread safety | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs:1000` (`_connection = connection`), `:1297` (`_primaryHost = host`) |
|
||||
| Driver.Mqtt.Contracts-001 | High | Resolved | Correctness & logic bugs | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttWriteEncoder.cs:119` |
|
||||
| Driver.OpcUaClient-001 | High | Resolved | Correctness & logic bugs | `OpcUaClientDriver.cs:444`, `:466`, `:517`, `:540`, `:599`, `:610` |
|
||||
| Driver.OpcUaClient-002 | High | Resolved | Error handling & resilience | `OpcUaClientDriver.cs:1330-1359` |
|
||||
| Driver.OpcUaClient-003 | High | Resolved | Correctness & logic bugs | `OpcUaClientDriver.cs:644-711` |
|
||||
@@ -213,6 +266,9 @@ Findings with status `Resolved`, `Won't Fix`, or `Deferred`.
|
||||
| Driver.AbLegacy-012 | Medium | Resolved | Design-document adherence | `PlcFamilies/AbLegacyPlcFamilyProfile.cs:7-54`, `AbLegacyDriver.cs:48-52` |
|
||||
| Driver.AbLegacy-014 | Medium | Resolved | Correctness & logic bugs | `LibplctagLegacyTagRuntime.cs:145` |
|
||||
| Driver.AbLegacy.Cli-001 | Medium | Resolved | Error handling & resilience | `Commands/WriteCommand.cs:46`, `Commands/WriteCommand.cs:62-72` |
|
||||
| Driver.Calculation-001 | Medium | Resolved | Correctness & logic bugs | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationDriver.cs:103-106` (pre-fix), `CalculationEvaluator.cs:47,56-59` (pre-fix) |
|
||||
| Driver.Calculation-003 | Medium | Resolved | Correctness & logic bugs / Code organization & conventions | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationDriverProbe.cs:16-21` (pre-fix) |
|
||||
| Driver.Calculation-004 | Medium | Resolved | Testing coverage | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationDriverProbe.cs`, `CalculationDriver.cs` (`ISkippedTagReporter`/`GetSkippedTags()`), `CalculationTimerScheduler.cs` (`SkippedTickCount`) |
|
||||
| Driver.Cli.Common-002 | Medium | Resolved | Correctness & logic bugs | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/SnapshotFormatter.cs:101-122` |
|
||||
| Driver.Cli.Common-003 | Medium | Resolved | Concurrency & thread safety | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/DriverCommandBase.cs:51-59` |
|
||||
| Driver.Cli.Common-005 | Medium | Resolved | Testing coverage | `tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.Tests/SnapshotFormatterTests.cs:27-37` |
|
||||
@@ -231,6 +287,7 @@ Findings with status `Resolved`, `Won't Fix`, or `Deferred`.
|
||||
| Driver.Galaxy-016 | Medium | Resolved | Performance & resource management | `ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj:43-47`, `libs/README.md:32-37` |
|
||||
| Driver.Galaxy.Browser-002 | Medium | Resolved | Concurrency & thread safety | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/GalaxyBrowseSession.cs:167` |
|
||||
| Driver.Galaxy.Contracts-002 | Medium | Resolved | Correctness & logic bugs | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/GalaxyDriverOptions.cs:91` |
|
||||
| Driver.Historian.Gateway-003 | Medium | Resolved | Testing coverage | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Recorder/GatewayHistorianValueWriter.cs`, `Recorder/HistorizationOutboxEntrySerializer.cs` |
|
||||
| Driver.Historian.Wonderware-002 | Medium | Resolved | Correctness and logic bugs | `Ipc/HistorianFrameHandler.cs:162`, `:181` |
|
||||
| Driver.Historian.Wonderware-003 | Medium | Resolved | Correctness and logic bugs | `Backend/HistorianDataSource.cs:320-323`, `:457-460` |
|
||||
| Driver.Historian.Wonderware-006 | Medium | Resolved | Error handling and resilience | `Ipc/PipeServer.cs:120-128` |
|
||||
@@ -241,6 +298,8 @@ Findings with status `Resolved`, `Won't Fix`, or `Deferred`.
|
||||
| Driver.Historian.Wonderware.Client-009 | Medium | Resolved | Testing coverage | `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/WonderwareHistorianClientTests.cs` |
|
||||
| Driver.Historian.Wonderware.Client.Contracts-002 | Medium | Deferred | Security | `WonderwareHistorianClientOptions.cs:50-54` |
|
||||
| Driver.Historian.Wonderware.Client.Contracts-003 | Medium | Resolved | Correctness & logic bugs | `WonderwareHistorianClientOptions.cs:26` |
|
||||
| Driver.MTConnect-001 | Medium | Resolved | Error handling & resilience | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs:1932` |
|
||||
| Driver.MTConnect-002 | Medium | Resolved | Correctness & logic bugs | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriverProbe.cs:192` |
|
||||
| Driver.Modbus-002 | Medium | Resolved | Correctness & logic bugs | `ModbusDriver.cs:127-186` |
|
||||
| Driver.Modbus-004 | Medium | Resolved | Performance & resource management | `ModbusDriver.cs:1468-1473` |
|
||||
| Driver.Modbus-005 | Medium | Resolved | Correctness & logic bugs | `ModbusDriver.cs:777-798,323-330` |
|
||||
@@ -254,6 +313,11 @@ Findings with status `Resolved`, `Won't Fix`, or `Deferred`.
|
||||
| Driver.Modbus.Cli-002 | Medium | Resolved | Correctness & logic bugs | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/WriteCommand.cs:54-89` |
|
||||
| Driver.Modbus.Contracts-001 | Medium | Resolved | Correctness & logic bugs | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusEquipmentTagParser.cs:35` |
|
||||
| Driver.Modbus.Contracts-002 | Medium | Resolved | Correctness & logic bugs | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusEquipmentTagParser.cs:36` |
|
||||
| Driver.Mqtt-003 | Medium | Resolved | Correctness & logic bugs | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs:376,903`; `MqttConnection.cs:474,486,657,1039,1134,1368,1384`; `Sparkplug/SparkplugPrimaryHost.cs:267` |
|
||||
| Driver.Mqtt.Browser-001 | Medium | Resolved | Security | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttBrowseSession.cs:898`, `:712` |
|
||||
| Driver.Mqtt.Contracts-002 | Medium | Resolved | Correctness & logic bugs | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttWriteEncoder.cs:169`, `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/Sparkplug/SparkplugTopic.cs:202` |
|
||||
| Driver.Mqtt.Contracts-003 | Medium | Resolved | Design-document adherence | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagDefinitionFactory.cs:361` |
|
||||
| Driver.Mqtt.Transport-001 | Medium | Resolved | Security | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Transport/MqttClientOptionsFactory.cs:120-128` (`ConfigureTls`) |
|
||||
| Driver.OpcUaClient-006 | Medium | Resolved | Concurrency & thread safety | `OpcUaClientDriver.cs:1330-1359` |
|
||||
| Driver.OpcUaClient-007 | Medium | Resolved | Concurrency & thread safety | `OpcUaClientDriver.cs:1374`, `:1376-1383`, `:508` |
|
||||
| Driver.OpcUaClient-008 | Medium | Resolved | Error handling & resilience | `OpcUaClientDriver.cs:1092-1099` |
|
||||
@@ -274,6 +338,8 @@ Findings with status `Resolved`, `Won't Fix`, or `Deferred`.
|
||||
| Driver.S7.Cli-002 | Medium | Resolved | Design-document adherence | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Commands/ReadCommand.cs:22-29`, `Commands/WriteCommand.cs:21-33`, `Commands/SubscribeCommand.cs:18-21`; `docs/Driver.S7.Cli.md:70-73,80-81` |
|
||||
| Driver.S7.Cli-003 | Medium | Resolved | Error handling & resilience | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Commands/ProbeCommand.cs:38-50` |
|
||||
| Driver.S7.Cli-008 | Medium | Resolved | Error handling & resilience | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/S7CommandBase.cs` |
|
||||
| Driver.Sql-001 | Medium | Resolved | Correctness & logic bugs | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlCatalogLoader.cs:139` |
|
||||
| Driver.Sql-003 | Medium | Resolved | Design-document adherence | `docs/drivers/Sql.md:55`, `docs/drivers/Sql.md:90` |
|
||||
| Driver.TwinCAT-003 | Medium | Resolved | Correctness & logic bugs | `AdsTwinCATClient.cs:264-281`, `283-300` |
|
||||
| Driver.TwinCAT-005 | Medium | Resolved | OtOpcUa conventions | `TwinCATDriver.cs` (whole file), `AdsTwinCATClient.cs` (whole file) |
|
||||
| Driver.TwinCAT-009 | Medium | Resolved | Concurrency & thread safety | `TwinCATDriver.cs:80-99`, `41-72`, `366-388` |
|
||||
@@ -468,6 +534,7 @@ Findings with status `Resolved`, `Won't Fix`, or `Deferred`.
|
||||
| Driver.Galaxy.Browser-004 | Low | Resolved | Testing coverage | `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests/GalaxyBrowseSessionTests.cs` |
|
||||
| Driver.Galaxy.Contracts-001 | Low | Resolved | Documentation & comments | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/GalaxyDriverOptions.cs:43` |
|
||||
| Driver.Galaxy.Contracts-003 | Low | Resolved | Code organization & conventions | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/GalaxyDriverBrowser.cs:149` (duplicate) and `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs:472` (original) |
|
||||
| Driver.Historian.Gateway-002 | Low | Resolved | Performance & resource management | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayTagProvisioner.cs` |
|
||||
| Driver.Historian.Wonderware-004 | Low | Resolved | Correctness and logic bugs | `Backend/SdkAlarmHistorianWriteBackend.cs:198-201` |
|
||||
| Driver.Historian.Wonderware-005 | Low | Resolved | Concurrency and thread safety | `Backend/HistorianDataSource.cs:124`, `:126-127` |
|
||||
| Driver.Historian.Wonderware-007 | Low | Resolved | Error handling and resilience | `Ipc/PipeServer.cs:70-75` |
|
||||
@@ -484,6 +551,8 @@ Findings with status `Resolved`, `Won't Fix`, or `Deferred`.
|
||||
| Driver.Historian.Wonderware.Client-012 | Low | Resolved | Performance & resource management | `Internal/FrameChannel.cs:38` |
|
||||
| Driver.Historian.Wonderware.Client-013 | Low | Resolved | Testing coverage | `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/ContractsWireParityTests.cs` |
|
||||
| Driver.Historian.Wonderware.Client.Contracts-004 | Low | Resolved | Documentation & comments | `WonderwareHistorianClientOptions.cs:50-54` |
|
||||
| Driver.MTConnect-008 | Low | Resolved | Documentation & comments | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs:3210` · `docs/drivers/MTConnect.md:169` · `MTConnectDriver.cs:1227`/`:1245`/`:1255` |
|
||||
| Driver.MTConnect.Contracts-001 | Low | Resolved | Documentation & comments | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/MTConnectDataTypeInference.cs:31` · `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/MTConnectDriverOptions.cs:86` |
|
||||
| Driver.Modbus-003 | Low | Resolved | Concurrency & thread safety | `ModbusDriver.cs:59,188,241,259,266,726,745,759` |
|
||||
| Driver.Modbus-007 | Low | Resolved | Design-document adherence | `ModbusDriver.cs:1392`, `ModbusDriverOptions.cs:74-80` |
|
||||
| Driver.Modbus-008 | Low | Resolved | Documentation & comments | `ModbusDriver.cs:411-417,700-703,737-744` |
|
||||
@@ -509,6 +578,11 @@ Findings with status `Resolved`, `Won't Fix`, or `Deferred`.
|
||||
| Driver.Modbus.Cli-011 | Low | Resolved | Correctness & logic bugs | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/SubscribeCommand.cs:119-122` |
|
||||
| Driver.Modbus.Contracts-003 | Low | Resolved | Documentation & comments | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusDriverOptions.cs:40` |
|
||||
| Driver.Modbus.Contracts-004 | Low | Resolved | Documentation & comments | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusDriverOptions.cs:53-60` |
|
||||
| Driver.Mqtt-004 | Low | Resolved | Error handling & resilience | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs:375` (`ShutdownAsync`) |
|
||||
| Driver.Mqtt-005 | Low | Resolved | Code organization & conventions | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttConnection.cs:1-4` |
|
||||
| Driver.Mqtt-008 | Low | Resolved | Testing coverage | `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/` (708 tests at `b990adae`) |
|
||||
| Driver.Mqtt-009 | Low | Resolved | Documentation & comments | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttConnection.cs:241-248` (type remarks) |
|
||||
| Driver.Mqtt-010 | Low | Resolved | Documentation & comments | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/SequenceTracker.cs:294` (`Reset`) |
|
||||
| Driver.OpcUaClient-011 | Low | Resolved | Documentation & comments | `OpcUaClientDriver.cs:1007-1015` |
|
||||
| Driver.OpcUaClient-014 | Low | Resolved | Performance & resource management | `OpcUaClientDriver.cs:1138`, `:1314` |
|
||||
| Driver.OpcUaClient-016 | Low | Resolved | Performance & resource management / Error handling & resilience | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs:934-948` |
|
||||
@@ -533,6 +607,7 @@ Findings with status `Resolved`, `Won't Fix`, or `Deferred`.
|
||||
| Driver.S7.Contracts-001 | Low | Resolved | Documentation & comments | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7DriverOptions.cs:107` |
|
||||
| Driver.S7.Contracts-002 | Low | Resolved | Correctness & logic bugs | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7EquipmentTagParser.cs:38` |
|
||||
| Driver.S7.Contracts-003 | Low | Deferred | Design-document adherence | `docs/v2/driver-specs.md:369` |
|
||||
| Driver.Sql-007 | Low | Resolved | Code organization & conventions | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj:21` |
|
||||
| Driver.TwinCAT-004 | Low | Resolved | Correctness & logic bugs | `TwinCATDataType.cs:24-27` |
|
||||
| Driver.TwinCAT-006 | Low | Resolved | OtOpcUa conventions | `TwinCATDriver.cs:406-411` |
|
||||
| Driver.TwinCAT-014 | Low | Resolved | Design-document adherence | `TwinCATDriverOptions.cs:41-43`, `TwinCATDriverOptions.cs:57-62`, `AdsTwinCATClient.cs:145` |
|
||||
|
||||
+63
-7
@@ -58,8 +58,8 @@ So everything below is **untracked work**. Nothing in it is a data-loss or outag
|
||||
| ~~**G-7 residual** — Sql / MTConnect / Calculation / OpcUaClient have no Contracts-side `Inspect`~~ ✅ **CLOSED 2026-08-09** (`2947a2b9` + `1469fbb4`, merge `0695f682`). All four now ship a Contracts-side `*TagDefinitionFactory.Inspect` **wrapping the runtime parse, never mirroring the AdminUI model** — Sql wraps the pre-existing `SqlEquipmentTagParser`; the MTConnect / OpcUaClient / Calculation blob readers were **moved** into their Contracts assemblies (the drivers now call them), so never-stricter-than-runtime is structural. `Driver.Calculation.Contracts` was created (no Contracts assembly existed). The reflection coverage guard was falsified (map entry removed → red naming `SqlTagDefinitionFactory`), and `TagConfigAuthoringDeployParityTests` pins authoring-accepts ⇒ deploy-accepts. **Galaxy is now the sole unmapped type, deliberately + symmetrically** (no typed authoring editor either). ~~Found en route, left open: `MTConnectTagConfigModel` reads `dataType` leniently (misspelled name → silently `String`) while the runtime rejects it — authoring weaker than runtime, the *opposite* direction from G-7; documented inline in the parity tests.~~ ✅ Filed and FIXED same day as **#549** — and it was 6 models wide, not 1 (see the "2026-08-09 (later)" §9 entry); residuals **#550**/**#551** are ✅ **CLOSED 2026-08-10** (merges `b50523b1` + `333ea488`, see the 2026-08-10 §9 entry) — their own residuals are **#552** (Sql browse-commit emits a config the gate rejects) / **#553** (CSV typed-column merge silently rewrites typo'd enums pre-gate). | ~~The deploy gate is weaker than the authoring gate.~~ Config arriving by CSV import or API now meets the same checks the modal applies. | §1.2 |
|
||||
| ~~**3 carried-open archreview findings** — the failure-visibility trio~~ **Update 2026-08-07 (truth sweep): mostly CLOSED.** 01/S-1 **FIXED** — `AddressSpaceApplier.cs:95-97` tallies swallowed removal-pass failures into `AddressSpaceApplyOutcome.FailedNodes` (comment cites 01/S-1). 03/S4 **FIXED** — `PrimaryGatePolicy.cs` default-denies on unknown role when a driver peer exists (doc-comment "Fixes archreview 03/S4"), live-proven R2-04 T15. 06/S-1 **PARTIAL** — fail-closed advise path + `galaxy.writes.unconfirmed` meter shipped in `GatewayGalaxyDataWriter.cs`; residual = gateway-side WriteComplete correlation (cross-repo, mxaccessgw). **Update 2026-08-09: the gateway half SHIPPED** — mxaccessgw main `b948e69` correlates the worker's `OnWriteComplete` onto the unary `WriteSecured` reply (`statuses[0]`; bounded wait default **1.5 s** — deliberately under OtOpcUa's 2 s Tier A Write budget, configurable via `MxGateway:Worker:WriteCompletionWaitMilliseconds`; timeout falls back to today's empty-statuses shape, still metered unconfirmed). **Update 2026-08-09 (later): 06/S-1 CLOSED — live-gated end to end, and the gate found + fixed three defects.** The user-authorized redeploy exposed (1) the correlation covered `WriteSecured` only while OtOpcUa's dominant no-login path emits plain `Write` — fixed gateway-side (worker `53f69cd`, all four unary write kinds, redeployed both hosts); (2) OtOpcUa's `StatusCodeMap.FromMxStatus` truncated the correlated failure row's MxStatus detail code to a byte (`1007 & 0xFF = 239` → OPC DA Good band), mapping a REFUSED write to Good — fixed with the not-IsSuccess-never-Good invariant + category routing (`SECURITY_ERROR → BadUserAccessDenied`); (3) the writer's borrow-from-subscription-registry optimization could never commit (a subscription handle is plain-advised; MXAccess refuses the no-login write `SECURITY_ERROR 1008` AND the advise upgrade `E_INVALIDARG` — one advise per HANDLE, not per attribute) — borrow path REMOVED, writer always owns a supervisory-advised handle. Live legs all PASSED on windev: happy write Good off confirmed `statuses[0]` + both meters flat; read-only refusal surfaces Bad-family + node-revert input; `WriteCompletionWaitMilliseconds=0` control → provisional Good + metered unconfirmed (override removed after). Pinned by `Live_correlated_write_statuses_confirm_commit_and_surface_refusal`, `Live_write_to_subscribed_tag_commits_via_own_handle`, `GatewayGalaxyWriteMeterLiveTests`, and the new `StatusCodeMapTests` rows. | ~~Residual is only the 06/S-1 live leg~~ **CLOSED 2026-08-09** — nothing remains. | §6.3 |
|
||||
| ~~**1 genuinely unexecuted plan** — `2026-05-29-adminui-followups`, **19 pending / 0 completed**~~ ✅ **CLOSED 2026-08-07 by the overlap audit it asked for — it was bookkeeping drift, not backlog.** All 19 tasks are shipped, superseded by v3, or moot; **no code was needed.** The warning was right and then some: WS1's whole *premise* died with v3 (driver-embedded tag lists in `DriverConfig` are no longer the runtime source of truth — the artifact populates `RawTags`), and its capability was delivered in the v3 shape (`TagConfigEditorMap` + `DeviceFormMap`, both verified **reachable**, not merely present). WS2/WS3 shipped; WS4's target files were deleted by v3 Batch 2 + #542. Per-task evidence in the `.tasks.json`. | §6.2 |
|
||||
| ~~**11 other open plan tasks**~~ **3 as of 2026-08-10** — ~~5 akka-hosting partials (likely v3-superseded)~~ *(verified superseded 2026-08-07 — F8/F9/F10/F13/F14 marked completed; `Phase7Composer`/`ScriptedAlarmActor` deleted, evaluators/SDK sinks/security policies all production-wired)*, ~~4+1 followups-batch (open *by choice*)~~ *(2026-08-10: Tasks 9 + 10 VERIFIED PASS live — Int64 authoring end-to-end on the rebuilt rig; probe E2E 12/12 with the "needs Windows-VM" premise corrected to TCP-reachability gating)*, ~~fixedtree Task 11~~ *(closed 2026-08-10: obsolete — the injection feature it gated was deleted with #507)*, ~~1 manual smoke checklist~~ *(closed 2026-08-10: obsolete — the driver pages it smokes were retired in v3 Batch 2)*. **Remaining 3: F10b + SetSeverity (reconsider gates, decisions already taken — do not build without a fresh go-ahead) and the device-gated proofs (hardware-blocked).** | Small, and two of the groups are decisions already taken. | §6.2, §9 2026-08-10 (sixth) |
|
||||
| **Review-coverage gap** — 10 shipped projects never reviewed (Calculation, Historian.Gateway, Mqtt +Browser/Contracts, MTConnect +Contracts, Sql +Browser/Contracts) while **3 modules still review the retired Wonderware backend** | Re-verified 2026-08-04 against `code-reviews/`. Unchanged since the audit. | §6.3 |
|
||||
| ~~**11 other open plan tasks**~~ **3 as of 2026-08-10** — ~~5 akka-hosting partials (likely v3-superseded)~~ *(verified superseded 2026-08-07 — F8/F9/F10/F13/F14 marked completed; `Phase7Composer`/`ScriptedAlarmActor` deleted, evaluators/SDK sinks/security policies all production-wired)*, ~~4+1 followups-batch (open *by choice*)~~ *(2026-08-10: Tasks 9 + 10 VERIFIED PASS live — Int64 authoring end-to-end on the rebuilt rig; probe E2E 12/12 with the "needs Windows-VM" premise corrected to TCP-reachability gating)*, ~~fixedtree Task 11~~ *(closed 2026-08-10: obsolete — the injection feature it gated was deleted with #507)*, ~~1 manual smoke checklist~~ *(closed 2026-08-10: obsolete — the driver pages it smokes were retired in v3 Batch 2)*. ~~**Remaining 3: F10b + SetSeverity (reconsider gates, decisions already taken — do not build without a fresh go-ahead) and the device-gated proofs (hardware-blocked).**~~ **Remaining 1 as of 2026-08-10 (later that day): F10b + SetSeverity reconsider gates put to the user and CLOSED decided-against (F10b re-open trigger: operator complaints about rebuild churn on data-type edits; SetSeverity is operationally invisible — engine overwrites on first eval); the device-gated proofs bundle trimmed to its 2 genuinely-gated sub-items (H6 native-ack→AVEVA + AbLegacy/TwinCAT/FOCAS probes).** | Hardware-blocked only. | §6.2, §9 2026-08-10 (sixth) |
|
||||
| ~~**Review-coverage gap** — 10 shipped projects never reviewed (Calculation, Historian.Gateway, Mqtt +Browser/Contracts, MTConnect +Contracts, Sql +Browser/Contracts) while **3 modules still review the retired Wonderware backend**~~ **CLOSED 2026-08-10**: all 14 unreviewed projects (the 10 + Mqtt.Transport, Sql.Dialect, Calculation.Contracts, OpcUaClient.Session) reviewed by 6 parallel agents — 61 findings, 26 fixed, 35 Open in `findings.md`; Wonderware reviews banner-retired to history; README at 65 modules, `--check` clean | §9 2026-08-10 (seventh) | §6.3, §9 2026-08-10 (seventh) |
|
||||
| ~~**`RawBrowseModal` stale-open race** (added 2026-08-05) — `OnParametersSetAsync` never re-checks staleness after its awaits, so a superseded open's late resolve can install the wrong device's browse config. The identical shape was found by review and **fixed** in `RawTagModal`/`RawManualTagEntryModal` (post-await staleness re-check; see `docs/plans/2026-08-05-raw-modal-picker-live-config-design.md` §6); the browse modal was deliberately left out of that branch's scope.~~ ✅ **CLOSED 2026-08-09** (`87637e4e`, merge `0739f4b9`) — the sibling `_openGeneration` guard ported to every post-await write, **plus two departures the siblings did not need**: this modal resolves inside `try/finally`, so the `catch` (a stale open's failure would paint its error over the live device) and the `finally` (a stale `return` still runs the tail, dropping the LIVE open's spinner) are generation-guarded too. Four `RawBrowseModalStaleOpenTests` facts, each observed red pre-guard and each re-falsified by reverting only its own guard (1-failed/3-passed per revert — no redundant pin). | ~~Lower stakes than the fixed pair… but the fix pattern is proven and cheap to port.~~ Ported. | design doc §6 |
|
||||
|
||||
⚠️ **One correction worth carrying forward.** archreview **03/S4** — "primary gate default-allows on
|
||||
@@ -1280,6 +1280,54 @@ drivers), 3 carried-open archreview findings led by 01/S-1 (`AddressSpaceApplier
|
||||
|
||||
---
|
||||
|
||||
### 2026-08-10 (seventh round) — review-coverage gap CLOSED: 14 modules reviewed, Wonderware reviews retired to history
|
||||
|
||||
The §0.1 "Review-coverage gap" row is done. Six parallel isolated-worktree agents (4 Opus on the big
|
||||
families, 2 Sonnet on the small ones) reviewed every never-reviewed project — the row's 10 plus
|
||||
`Mqtt.Transport`, `Sql.Dialect`, `Calculation.Contracts` and `OpcUaClient.Session` (the last absent
|
||||
from both the row and the request; found by listing `src/` against `code-reviews/`). All six branches
|
||||
merged `--no-ff`; consolidated verification on the merged tree: build clean, **11 suites / 4,033
|
||||
tests green, 0 failed** (Mqtt 729 proves the two Mqtt branches compose: 708 base + 16 + 5). README
|
||||
regenerated — **65 modules**, `--check` clean.
|
||||
|
||||
**61 findings recorded; 26 fixed TDD-red-first on the branches; 35 recorded Open** (cross-module,
|
||||
design-decision, or Low record-only — the same disposition split as the 2026-06-19 sweep). The fixed
|
||||
Highs, all previously invisible to every suite:
|
||||
|
||||
- **Historian.Gateway**: alarm severity written as enum NAME while the read side `int.TryParse`s it —
|
||||
every read-back collapsed to severity 1, and an existing test asserted the wrong value as correct.
|
||||
- **Mqtt.Contracts**: a write to a wildcard `writeTopic` published an MQTT-3.3.2-2 protocol violation —
|
||||
a conformant broker closes the connection, taking the whole driver (every subscription) dark.
|
||||
- **Mqtt runtime ×2, same class — "state that outlives the session it describes"**: the plain-path
|
||||
subscription manager never cleared `_subscribedTopics` across a session rebuild, so the #527
|
||||
watchdog's automatic reconnect came back deaf (Connected/Healthy, zero subscriptions, values frozen);
|
||||
and `InitializeCoreAsync` overwrote live `_connection`/`_primaryHost` without teardown, stranding a
|
||||
connected broker session (reachable via `AdoptConfigDuringInit` racing a reconnect). The Sparkplug
|
||||
siblings had both hardened already — worth re-checking other drivers keeping per-session bookkeeping
|
||||
across Shutdown/Initialize.
|
||||
- **Calculation**: multi-dep tags evaluated against partial snapshots during warm-up — a spurious
|
||||
"script threw" Warning on every ordinary deploy for the standard calc-tag shape.
|
||||
|
||||
Notable Open (recorded in `findings.md`, fix sites named): **Driver.MTConnect-003 (High,
|
||||
cross-module)** — browse-committed MTConnect tags coerce to `String` at runtime while their nodes are
|
||||
typed from the same probe inference (the #536 class; fix in AdminUI `RawBrowseCommitMapper`, which got
|
||||
the typed-model branch without the native-type forwarding its 3 siblings have); Driver.Sql-002
|
||||
(unchunked `IN` list vs. the 2100-param SqlClient cap); Driver.Sql.Contracts-002 (cross-module:
|
||||
`Enum.TryParse` accepts numeric strings, so `"type":"99"` deploys an undefined `DriverDataType` —
|
||||
belongs in `Core.Abstractions`); Driver.Calculation-005 (evaluation runs under `_lock` on the mux
|
||||
actor thread, up to `RunTimeout`).
|
||||
|
||||
Also this round: the **three `Historian.Wonderware*` review modules got retired-module banners**
|
||||
(backend deleted in the gateway cutover, zero slnx references; Server/Admin deleted-module precedent)
|
||||
and the untracked `bin`/`obj` litter dirs under `src/Drivers/` were removed. Process traps for next
|
||||
time: (1) my brief's regen-readme gotcha was worded over-broadly and one agent made its finding
|
||||
headings `####` — **invisible to the generator's `^###\s+` split**; `--check` catches it only when the
|
||||
header claims Open findings, so a 0-open file fails silently — audit `grep -c '^### '` per new file;
|
||||
(2) the harness Write tool refuses subagent-written `findings.md` (pattern-matches "report files") —
|
||||
agents worked around via scratchpad-copy or heredoc, legitimately.
|
||||
|
||||
---
|
||||
|
||||
### 2026-08-10 (sixth round) — open-plan-task sweep: 2 verified live, 2 closed obsolete; 7 → 3
|
||||
|
||||
A freshness audit of the 7 open plan tasks (parsed from `*.tasks.json`, per the re-derive rule)
|
||||
@@ -1313,11 +1361,19 @@ against the current tree, then one Opus agent ran the two runnable verifies. No
|
||||
no driver pages exist under `Components/Pages/`. The form bodies live on inside the `/raw`
|
||||
modals, live-gated repeatedly across the 2026-07/08 rounds.
|
||||
|
||||
**Open plan tasks are now 3**, all deliberate: F10b surgical DataType/IsArray + alarm-severity
|
||||
`SetSeverity` (reconsider gates — premises re-verified still true on the current tree:
|
||||
`TagDeltaIsSurgicalEligible` still rebuild-forces DataType/IsArray; the node manager still projects
|
||||
engine-computed severity) and the device-gated proofs (hardware-blocked; two sub-items possibly
|
||||
part-covered by the 2026-06-27 historian gate and `dcb0be65`, worth trimming when next touched).
|
||||
~~**Open plan tasks are now 3**~~ **now 1** (updated later 2026-08-10), all deliberate: ~~F10b
|
||||
surgical DataType/IsArray + alarm-severity `SetSeverity` (reconsider gates — premises re-verified
|
||||
still true on the current tree: `TagDeltaIsSurgicalEligible` still rebuild-forces DataType/IsArray;
|
||||
the node manager still projects engine-computed severity)~~ *both reconsider gates were put to the
|
||||
user later that day and CLOSED decided-against — F10b: the rebuild path is correct and the surgical
|
||||
swap buys a rare edit at the cost of a client-visible atomicity window + ModelChangeEvent surface +
|
||||
a mandatory live gate (re-open trigger: operator complaints about rebuild churn on data-type edits);
|
||||
SetSeverity: operationally invisible, the engine overwrites authored severity on first eval* — and
|
||||
the device-gated proofs (hardware-blocked; ~~two sub-items possibly part-covered by the 2026-06-27
|
||||
historian gate and `dcb0be65`, worth trimming when next touched~~ *trimmed 2026-08-10: Phase C T7
|
||||
(backend retired; HistoryRead-vs-AVEVA proven by the cutover gate + #491 read-back) and Phase B T9
|
||||
(equivalent proof live via `dcb0be65`) both removed — the bundle is now H6 native-ack→AVEVA + the
|
||||
AbLegacy/TwinCAT/FOCAS probe happy-paths, all genuinely gated*).
|
||||
Side effect worth knowing: **the docker-dev rig now runs current master** (`6e7028e0856f`).
|
||||
|
||||
---
|
||||
|
||||
@@ -167,9 +167,11 @@ universal browser commits and the key the driver resolves reads/subscribes
|
||||
against. `MTConnectTagConfigModel.FromJson` (the AdminUI typed editor) tries
|
||||
three spellings in order — `fullName` → `dataItemId` → `address` — and takes
|
||||
the first non-blank one, normalising onto `fullName` on save. `address` is
|
||||
accepted because that's the field name `RawBrowseCommitMapper` writes for a
|
||||
driver with no typed editor path, so a browse-committed tag stays readable
|
||||
even before the editor round-trips it.
|
||||
accepted because that is the field name `RawBrowseCommitMapper` writes for a
|
||||
driver with no typed-editor branch of its own — MTConnect has had such a
|
||||
branch since Gitea #536 and now commits under `fullName`, but blobs committed
|
||||
before it, and hand-authored/CSV/API blobs, still carry `address` and must
|
||||
stay readable.
|
||||
|
||||
### Coercion-type precedence
|
||||
|
||||
@@ -192,6 +194,16 @@ into) is resolved in this order, first non-null wins:
|
||||
(category/type/units/representation → `DriverDataType`).
|
||||
4. `DriverDataType.String` — the coercion that cannot fail.
|
||||
|
||||
> ⚠️ **Step 3 does not currently rescue a browse-committed tag.** `RawBrowseCommitMapper`'s
|
||||
> MTConnect branch emits a whole `MTConnectTagConfigModel`, whose `ToJson()` always writes
|
||||
> `dataType` from a field defaulting to `String` — a non-blank authored type, so step 1 wins
|
||||
> and step 3 is never consulted. The raw tag's OPC UA `DataType` meanwhile *is* taken from the
|
||||
> browsed leaf's inferred type (`RawBrowseCommitMapper.MapLeaf` → `MapDataType`), so a browsed
|
||||
> `SAMPLE` lands as a `Double` node whose driver coerces the observation to text. Fix belongs in
|
||||
> the commit mapper (forward the leaf's `DriverDataType` into the model, as the AbCip / TwinCAT /
|
||||
> FOCAS branches already do). Recorded as `Driver.MTConnect-003` in
|
||||
> [`code-reviews/Driver.MTConnect/findings.md`](../../code-reviews/Driver.MTConnect/findings.md).
|
||||
|
||||
### Quality mapping
|
||||
|
||||
`UNAVAILABLE` — MTConnect's one explicit "I have no value" sentinel — maps to
|
||||
|
||||
@@ -404,6 +404,17 @@ nothing forever, so `OnReconnectedAsync` **throws** when zero filters are grante
|
||||
down rather than swallowing it. A SUBACK failure degrades just the affected RawPaths to
|
||||
`BadCommunicationError`.
|
||||
|
||||
There are **two** kinds of reconnect and only one of them fires that callback. A drop the connection's
|
||||
own supervisor recovers keeps the same `MqttConnection`, so `Reconnected` → `OnReconnectedAsync`
|
||||
re-establishes every live filter. A **session rebuild** — `ShutdownAsync` + `InitializeAsync` (the #527
|
||||
comms-loss watchdog's retry path, and an `ApplyDelta` that changes the endpoint or credentials) — builds
|
||||
a *new* connection and re-attaches the *same* `MqttSubscriptionManager`, and the re-subscribe then comes
|
||||
from the host (`DriverInstanceActor.ResubscribeDesired`) rather than from the callback. `AttachTo`
|
||||
therefore **forgets the filters the previous session had established**: the dedupe set is a claim about
|
||||
one session, and carrying it across made the host's re-subscribe resolve an empty pending set, send no
|
||||
SUBSCRIBE at all, and leave the driver Connected/Healthy and permanently deaf. Sparkplug's
|
||||
`SparkplugIngestor.AttachTo` resets its own session state for the same reason.
|
||||
|
||||
## Write-through
|
||||
|
||||
`IWritable` landed with [#508](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/508). Before it,
|
||||
|
||||
+16
-4
@@ -56,13 +56,18 @@ One row per tag. The driver matches `keyColumn = keyValue` and reads `valueColum
|
||||
The tag names its `columnName`; the row is picked either by a `whereColumn`/`whereValue` pair or by
|
||||
newest-timestamp.
|
||||
|
||||
⚠️ **The selector keys live inside a nested `rowSelector` object**, not at the top level. A blob that
|
||||
spells `whereColumn` / `whereValue` / `topByTimestamp` beside `columnName` carries *no* row selector
|
||||
as far as `SqlEquipmentTagParser` is concerned: the tag is refused, the driver skips it, and it reads
|
||||
`BadNodeIdUnknown` forever. The typed editor, the browse-commit mapper and the deploy gate all use
|
||||
the nested form.
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "WideRow",
|
||||
"table": "dbo.LineStatus",
|
||||
"columnName": "OvenTemp",
|
||||
"whereColumn": "LineId",
|
||||
"whereValue": "L1"
|
||||
"rowSelector": { "whereColumn": "LineId", "whereValue": "L1" }
|
||||
}
|
||||
```
|
||||
|
||||
@@ -71,10 +76,13 @@ newest-timestamp.
|
||||
"model": "WideRow",
|
||||
"table": "dbo.Readings",
|
||||
"columnName": "OvenTemp",
|
||||
"topByTimestamp": "CapturedUtc"
|
||||
"rowSelector": { "topByTimestamp": "CapturedUtc" }
|
||||
}
|
||||
```
|
||||
|
||||
`whereValue` may be authored as a JSON string, number or boolean (it is bound as a parameter either
|
||||
way). `keyValue` on the `KeyValue` model must be a **string**.
|
||||
|
||||
### `Query` — deferred to P3
|
||||
|
||||
Named arbitrary-`SELECT`. **The v1 parser rejects it**, and that refusal is enforced end-to-end —
|
||||
@@ -87,7 +95,11 @@ ignoring it later.
|
||||
|---|---|
|
||||
| `timestampColumn` | Source timestamp for the value. Absent ⇒ the driver stamps the poll time. |
|
||||
| `type` | Explicit OPC UA type override. Absent ⇒ inferred from the result set's column metadata. |
|
||||
| `pollIntervalMs` | Per-tag override of the instance's `defaultPollInterval`. |
|
||||
|
||||
There is **no per-tag poll-interval field**. The interval is the subscriber's, floored by
|
||||
`PollGroupEngine.DefaultMinInterval`, falling back to the instance's `defaultPollInterval` when the
|
||||
subscriber names none. (A `pollIntervalMs` per-tag override was documented here for a while and never
|
||||
existed in the parser — nothing reads it.)
|
||||
|
||||
## Driver config
|
||||
|
||||
|
||||
@@ -76,11 +76,11 @@ build/test/rig. Finish a batch = ff-merge to master + push.
|
||||
3. If any sub-part balloons >~300 LOC, split it out. Build; OpcUaServer + Runtime tests green.
|
||||
4. Commit `feat(opcua): emit Bad blip + AuditWriteUpdateEvent + sync fail-fast on failed device write`.
|
||||
|
||||
### Task 7 (B4): F10b surgical DataType/IsArray in-place writes — **RECONSIDER GATE**
|
||||
**Classification:** standard · **Do NOT build without an explicit fresh go-ahead** (previously decided against as dirty — brief value-type mismatch, no ModelChangeEvents, rare edits). If approved: extend `ISurgicalAddressSpaceSink.UpdateTagAttributes` to swap DataType/ValueRank in place + emit ModelChangeEvents; widen `Phase7Applier.TagDeltaIsSurgicalEligible`; **live-`/run` the rebuild=False path** (the prod-inertness trap, see the F10b deferred-wrapper lesson). Until approved this stays a deferred record.
|
||||
### Task 7 (B4): F10b surgical DataType/IsArray in-place writes — **CLOSED: DECIDED AGAINST (2026-08-10)**
|
||||
**Classification:** standard · Previously decided against as dirty (brief value-type mismatch, no ModelChangeEvents, rare edits); the reconsider gate was put to the user 2026-08-10 and the decision confirmed: **do not build.** The rebuild path handles DataType/IsArray edits *correctly*, just heavily; the surgical path would buy a rare authoring correction at the cost of a client-visible atomicity window (cached DataType vs. last published value), new ModelChangeEvent spec surface, and a mandatory live-`/run` of the rebuild=False path (the prod-inertness trap). **Re-open trigger:** operators complaining about rebuild/subscription churn on data-type edits — that evidence, and nothing weaker, reopens this.
|
||||
|
||||
### Task 8 (B5): Alarm-severity `SetSeverity` surgical update — **RECONSIDER GATE**
|
||||
**Classification:** small · **Do NOT build without an explicit fresh go-ahead** (operationally invisible — the alarm engine overwrites authored severity on first eval). Recorded so the decision isn't a silent gap.
|
||||
### Task 8 (B5): Alarm-severity `SetSeverity` surgical update — **CLOSED: DECIDED AGAINST (2026-08-10)**
|
||||
**Classification:** small · Reconsider gate put to the user 2026-08-10; decision confirmed: **do not build.** Operationally invisible — the alarm engine overwrites authored severity with its computed value on the condition's first evaluation (re-verified on the current tree: `OtOpcUaNodeManager` still projects engine-computed severity), so the surgical write would be observable only between deploy and the next transition.
|
||||
|
||||
### Task 9 (C1): Modbus-Int64 full live authoring — **VERIFY-ONLY (operator/rig)**
|
||||
**Classification:** verify · Seed a Modbus driver on docker-dev → sim `10.100.0.35:5020`, author an Int64 equipment tag, deploy, confirm the OPC UA node advertises `DataTypeIds.Int64` + reads changing. No code unless a gap surfaces.
|
||||
@@ -89,4 +89,6 @@ build/test/rig. Finish a batch = ff-merge to master + push.
|
||||
**Classification:** verify · `lmxopcua-fix up s7 s7_1500` / `up abcip controllogix` from the Windows VM, then run the skip-gated probe E2E green path (`DriverProbeHandshakeE2eTests`).
|
||||
|
||||
### Task 11 (C3): Device-gated proofs — **BLOCKED (hardware)**
|
||||
**Classification:** blocked · H6 native-ack→AVEVA, Galaxy Phase C historian T7, Phase B T9, AbLegacy/TwinCAT/FOCAS probe happy-paths — need Wonderware+AVEVA (`10.100.0.48`), a Galaxy native alarm, PLC5/SLC sim, ADS target, CNC+FWLIB. Captured; not executable here.
|
||||
**Classification:** blocked · H6 native-ack→AVEVA + AbLegacy/TwinCAT/FOCAS probe happy-paths — need a Galaxy native alarm + AVEVA (`10.100.0.48`), PLC5/SLC sim, ADS target, CNC+FWLIB. Captured; not executable here.
|
||||
|
||||
**Trimmed 2026-08-10 (audit):** two sub-items removed as covered by later work — **Galaxy Phase C historian T7** (its target backend, the Wonderware sidecar + `ServerHistorian:Host/Port/SharedSecret`, was retired in the HistorianGateway cutover; the substantive proof — OPC UA HistoryRead returning real-AVEVA samples — was delivered by the cutover live gate (read ✅, `docs/plans/2026-06-27-otopcua-historian-followups.md`) and the #491 read-back leg; historization is driver-agnostic, so no Galaxy-specific leg remains) and **Phase B T9** (equivalent proof achieved live via `dcb0be65`: the full native-alarm chain verified end-to-end — Part 9 condition materialized, `/alerts` row rendered — as `docs/plans/2026-06-18-stillpending-backlog-audit.md` §C already ruled). H6's live leg remains genuinely gated: it needs an *ack of a real Galaxy native alarm* flowing to AVEVA event history — the cutover gate proved the SendEvent transport but not this composition.
|
||||
|
||||
@@ -133,18 +133,20 @@
|
||||
"ref": "B4",
|
||||
"subject": "F10b surgical DataType/IsArray in-place writes",
|
||||
"classification": "standard",
|
||||
"status": "pending",
|
||||
"status": "closed",
|
||||
"gate": "RECONSIDER \u2014 previously decided against (dirty); do NOT build without explicit go-ahead",
|
||||
"group": "B-reconsider"
|
||||
"group": "B-reconsider",
|
||||
"notes": "CLOSED 2026-08-10: decided against, user-confirmed. Rebuild path is correct (just heavy); surgical swap adds a client-visible atomicity window + ModelChangeEvent surface + mandatory live-gate for a rare edit. Re-open trigger: operator complaints about rebuild churn on data-type edits."
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"ref": "B5",
|
||||
"subject": "Alarm-severity SetSeverity surgical update",
|
||||
"classification": "small",
|
||||
"status": "pending",
|
||||
"status": "closed",
|
||||
"gate": "RECONSIDER \u2014 operationally invisible (alarm engine overwrites); do NOT build without explicit go-ahead",
|
||||
"group": "B-reconsider"
|
||||
"group": "B-reconsider",
|
||||
"notes": "CLOSED 2026-08-10: decided against, user-confirmed. Operationally invisible - the alarm engine overwrites authored severity on first eval (re-verified: OtOpcUaNodeManager still projects engine-computed severity)."
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
@@ -167,12 +169,13 @@
|
||||
{
|
||||
"id": 11,
|
||||
"ref": "C3",
|
||||
"subject": "Device-gated proofs (H6 ack\u2192AVEVA, Phase C T7, Phase B T9, AbLegacy/TwinCAT/FOCAS probe)",
|
||||
"subject": "Device-gated proofs (H6 ack->AVEVA, AbLegacy/TwinCAT/FOCAS probe) - trimmed 2026-08-10",
|
||||
"classification": "blocked",
|
||||
"status": "partial",
|
||||
"note": "Live-assessed 2026-06-19 (gateway 10.100.0.48:5120 reachable from this Mac, so the 'all blocked' label was partly stale). OUTCOMES: (Phase B T9) DONE \u2014 native-alarm PIPELINE proven end-to-end live on docker-dev: authored a Galaxy alarm tag (FullName=Galaxy!TestArea.TestMachine_001.TestAlarm001, alarm{alarmType:OffNormalAlarm,severity:750}) -> Galaxy driver opened the native-alarm subscription (previously-gated path) -> gateway transition materialised Part 9 condition GalaxyNativeAlarm (ns=2;s=EQ-55297329838d/GalaxyNativeAlarm) carrying the EXACT gateway raise time 2026-05-24T16:04:10.856Z, surfaced via ConditionRefresh. KEY FINDING: ConditionId match REQUIRES the 'Galaxy!' source prefix on the authored FullName (without it every transition is silently dropped). Severity 700 = MapSeverity(750) (MediumHigh bucket), not a bug. Net state Inactive/Unacked because the gateway replays weeks-old historical raises -> a fresh Active-state + live /alerts row needs an alarm triggered on the AVEVA side. (Phase C T7) DEFERRED config+data-gated \u2014 confirmed the historized Galaxy node materialises + server HistoryRead path returns GoodNoData/0-values with ServerHistorian.Enabled=false (correct NullHistorianDataSource degrade); live data needs enabling ServerHistorian + Wonderware sidecar on 10.100.0.48 + actual stored history (synthetic ref has none). (H6 ack->AVEVA) DEFERRED \u2014 ack SEND reachable but Galaxy AckMsg is write-only/fire-and-forget; confirming it LANDED needs AVEVA-side observation on 10.100.0.48. (AbLegacy/FOCAS/TwinCAT probe) DEFERRED \u2014 no local sims running + no live-target probe harness; TwinCAT(ADS) genuinely has no rig target. Rig restored (alarm tag removed, Galaxy back to value-only).",
|
||||
"group": "C-blocked"
|
||||
"group": "C-blocked",
|
||||
"notes": "Trimmed 2026-08-10: Phase C T7 removed (Wonderware sidecar target retired in the HistorianGateway cutover; HistoryRead-vs-real-AVEVA proven by the cutover live gate read leg + #491 read-back; historization is driver-agnostic so no Galaxy-specific leg remains). Phase B T9 removed (equivalent proof live via dcb0be65 - Part 9 condition + /alerts row end-to-end; already ruled by the 2026-06-18 backlog audit sC). Remaining, genuinely gated: H6 native-ack->AVEVA (needs an ack of a REAL Galaxy native alarm reaching AVEVA event history - the cutover gate proved SendEvent transport, not this composition) and the AbLegacy/TwinCAT/FOCAS probe happy-paths (PLC5/SLC sim, ADS target, CNC+FWLIB)."
|
||||
}
|
||||
],
|
||||
"lastUpdated": "2026-06-19"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,10 +100,10 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
|
||||
_driverInstanceId = driverInstanceId;
|
||||
_logger = logger ?? NullLogger<CalculationDriver>.Instance;
|
||||
_scriptRoot = scriptRoot ?? new ScriptRootLogger(new LoggerConfiguration().CreateLogger());
|
||||
_evaluator = new CalculationEvaluator(
|
||||
_logger is ILogger<CalculationEvaluator> el ? el : NullLogger<CalculationEvaluator>.Instance,
|
||||
_scriptRoot,
|
||||
_options.RunTimeout);
|
||||
// _logger (ILogger<CalculationDriver>) implements the non-generic ILogger, so it is passed straight
|
||||
// through — the evaluator's host-side diagnostics (compile errors, sandbox violations, execution
|
||||
// throws) land on the SAME logger the driver itself was constructed with, not a separate one.
|
||||
_evaluator = new CalculationEvaluator(_logger, _scriptRoot, _options.RunTimeout);
|
||||
|
||||
RebuildTagTable(_options);
|
||||
}
|
||||
@@ -379,7 +379,14 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
|
||||
var st = _stateByRawPath[rawPath];
|
||||
|
||||
// Change-gate (mini-design §4): publish nothing until every declared dep has arrived at least once.
|
||||
var allArrived = def.DependencyRefs.All(_arrivedDeps.Contains);
|
||||
// MUST be checked BEFORE the evaluator runs, not just before the publish: a partially-warmed-up
|
||||
// dependency snapshot is missing entries for the not-yet-arrived deps, and VirtualTagContext.GetTag
|
||||
// returns a Bad snapshot (.Value == null) for those — so the common `(double)ctx.GetTag("x").Value`
|
||||
// idiom throws NullReferenceException on every warm-up tick for any multi-dependency script whose
|
||||
// deps don't all arrive in the same tick. Evaluating anyway (and discarding the result afterwards)
|
||||
// used to turn ordinary warm-up into a spurious "script threw" script-log Warning on every partial
|
||||
// arrival, plus a wasted Roslyn/compiled-cache invocation.
|
||||
if (!def.DependencyRefs.All(_arrivedDeps.Contains)) return; // still warming up — do not evaluate yet
|
||||
|
||||
// Build the per-tag dependency snapshot (only its declared reads, current values).
|
||||
var deps = new Dictionary<string, object?>(StringComparer.Ordinal);
|
||||
@@ -390,12 +397,10 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
OnEvaluationFailed(def, st, result.Reason ?? "evaluator failure", allArrived, timestampUtc);
|
||||
OnEvaluationFailed(def, st, result.Reason ?? "evaluator failure", timestampUtc);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!allArrived) return; // change-trigger warm-up: hold until inputs are ready
|
||||
|
||||
// Value dedup — but bypass it while recovering from Bad so a recovery whose value equals the
|
||||
// pre-failure value still republishes Good (otherwise the node would stay Bad forever).
|
||||
if (!st.LastPublishedBad && st.HasValue && Equals(st.LastValue, result.Value))
|
||||
@@ -411,12 +416,13 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
|
||||
|
||||
/// <summary>Failure tail (mirrors <c>VirtualTagActor</c> 02/S13): always emits the per-failure
|
||||
/// script-log; additionally publishes a Bad snapshot — carrying the last-known value — once per
|
||||
/// Good→Bad transition and only after every declared dependency has arrived.</summary>
|
||||
private void OnEvaluationFailed(
|
||||
CalculationTagDefinition def, TagState st, string reason, bool allArrived, DateTime timestampUtc)
|
||||
/// Good→Bad transition. The caller (<see cref="EvaluateTag"/>) only reaches here once every declared
|
||||
/// dependency has arrived — the warm-up gate returns before the evaluator ever runs — so there is no
|
||||
/// separate "inputs ready" condition to re-check here.</summary>
|
||||
private void OnEvaluationFailed(CalculationTagDefinition def, TagState st, string reason, DateTime timestampUtc)
|
||||
{
|
||||
PublishScriptLog(def, reason);
|
||||
if (st.LastPublishedBad || !allArrived) return;
|
||||
if (st.LastPublishedBad) return;
|
||||
|
||||
st.LastPublishedBad = true;
|
||||
st.LastTimestampUtc = timestampUtc;
|
||||
|
||||
@@ -13,10 +13,16 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation;
|
||||
/// </summary>
|
||||
public sealed class CalculationDriverProbe : IDriverProbe
|
||||
{
|
||||
// Mirrors CalculationDriverFactoryExtensions.JsonOptions / CalculationDriver.ReinitJsonOptions
|
||||
// (AllowTrailingCommas + ReadCommentHandling included) so a config blob the real spawn/reinit path
|
||||
// tolerates never fails Test-Connect as "invalid JSON" — the probe must never be STRICTER than the
|
||||
// path it is standing in for.
|
||||
private static readonly JsonSerializerOptions Opts = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
|
||||
@@ -44,17 +44,20 @@ public sealed class CalculationEvaluator : IScriptCacheOwner, IDisposable
|
||||
// that dispose each evaluator's collectible AssemblyLoadContext. ClearCompiledScripts() — driven by the
|
||||
// WP7 driver per deploy generation — is what stops ALCs accreting across script edits.
|
||||
private readonly CompiledScriptCache<VirtualTagContext, object?> _cache = new();
|
||||
private readonly ILogger<CalculationEvaluator> _logger;
|
||||
private readonly ILogger _logger;
|
||||
private readonly SerilogLogger _scriptRoot;
|
||||
private readonly TimeSpan _runTimeout;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>Initializes a new <see cref="CalculationEvaluator"/>.</summary>
|
||||
/// <param name="logger">Logger for host-side compilation/execution diagnostics.</param>
|
||||
/// <param name="logger">Logger for host-side compilation/execution diagnostics. Deliberately the
|
||||
/// non-generic <see cref="ILogger"/> (rather than <c>ILogger<CalculationEvaluator></c>) so the
|
||||
/// caller can forward its OWN <c>ILogger<T></c> — every <c>ILogger<T></c> already
|
||||
/// implements this — instead of needing a second, separately-obtained logger instance.</param>
|
||||
/// <param name="scriptRoot">Root script logger; user <c>ctx.Logger.*</c> output flows through this to the Script-log page.</param>
|
||||
/// <param name="runTimeout">Wall-clock budget per script run; defaults to 2 seconds (parity with the VT evaluator).</param>
|
||||
public CalculationEvaluator(
|
||||
ILogger<CalculationEvaluator> logger,
|
||||
ILogger logger,
|
||||
ScriptRootLogger scriptRoot,
|
||||
TimeSpan? runTimeout = null)
|
||||
{
|
||||
|
||||
@@ -17,7 +17,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway;
|
||||
/// as <see cref="HistorianProvisionResult.Failed"/> and returns. The method never throws and never
|
||||
/// logs tag values, hostnames, or credentials.
|
||||
/// </remarks>
|
||||
public sealed class GatewayTagProvisioner : IHistorianProvisioning
|
||||
public sealed class GatewayTagProvisioner : IHistorianProvisioning, IAsyncDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Storage rate (ms) stamped on every provisioned analog tag. The gateway's <c>EnsureTags</c> path
|
||||
@@ -104,4 +104,13 @@ public sealed class GatewayTagProvisioner : IHistorianProvisioning
|
||||
return new HistorianProvisionResult(requests.Count, Ensured: 0, Skipped: skipped, Failed: definitions.Count);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes the underlying gateway client (and its gRPC channel). The DI container owns this
|
||||
/// provisioner as a singleton (<c>AddHistorianProvisioning</c>), so this fires once at host
|
||||
/// shutdown — closing the channel gracefully, matching the read (<see cref="GatewayHistorianDataSource"/>)
|
||||
/// and alarm-write (<see cref="GatewayAlarmHistorianWriter"/>) paths' dispose ownership.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public ValueTask DisposeAsync() => _client.DisposeAsync();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using ZB.MOM.WW.HistorianGateway.Contracts.Grpc;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Mapping;
|
||||
@@ -10,6 +11,25 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Mapping;
|
||||
/// </summary>
|
||||
internal static class AlarmEventMapper
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps <see cref="AlarmSeverity"/> to the numeric OPC UA Part 9 priority (1-1000) carried in the
|
||||
/// wire event's <c>Severity</c> property, per the <see cref="AlarmHistorianEvent.Severity"/>
|
||||
/// contract ("Mapped to Historian's numeric priority on the sink side"). The read side
|
||||
/// (<see cref="EventMapper.ToHistoricalEvent"/>) <c>int.TryParse</c>s this same property, so it
|
||||
/// must stay a numeric string — the enum's <c>.ToString()</c> name (e.g. "High") is NOT
|
||||
/// parseable and used to silently collapse every read-back severity to the minimum (1). The
|
||||
/// values mirror the existing native-alarm convention
|
||||
/// (<c>ZB.MOM.WW.OtOpcUa.Runtime.Drivers.NativeAlarmProjector.MapSeverity</c>).
|
||||
/// </summary>
|
||||
private static string ToNumericSeverity(AlarmSeverity severity) => severity switch
|
||||
{
|
||||
AlarmSeverity.Low => "200",
|
||||
AlarmSeverity.Medium => "500",
|
||||
AlarmSeverity.High => "700",
|
||||
AlarmSeverity.Critical => "900",
|
||||
_ => "500",
|
||||
};
|
||||
|
||||
/// <summary>Maps an alarm historian event to a gateway event.</summary>
|
||||
/// <param name="alarm">The driver-agnostic alarm event.</param>
|
||||
/// <returns>The gateway wire event ready for <c>SendEvent</c>.</returns>
|
||||
@@ -36,7 +56,7 @@ internal static class AlarmEventMapper
|
||||
historianEvent.Properties["AlarmId"] = alarm.AlarmId;
|
||||
historianEvent.Properties["AlarmName"] = alarm.AlarmName;
|
||||
historianEvent.Properties["EventKind"] = alarm.EventKind;
|
||||
historianEvent.Properties["Severity"] = alarm.Severity.ToString();
|
||||
historianEvent.Properties["Severity"] = ToNumericSeverity(alarm.Severity);
|
||||
historianEvent.Properties["User"] = alarm.User;
|
||||
historianEvent.Properties["Message"] = alarm.Message;
|
||||
if (alarm.Comment is not null)
|
||||
|
||||
+15
-5
@@ -29,11 +29,21 @@ public readonly record struct MTConnectInferredType(DriverDataType DataType, boo
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This lives in <c>.Contracts</c> because three call sites must agree byte-for-byte, or a
|
||||
/// tag's OPC UA type stops matching its authored config: <c>ITagDiscovery.DiscoverAsync</c>
|
||||
/// (stamps <see cref="DriverAttributeInfo.DriverDataType" /> on every browsed leaf), the
|
||||
/// universal browser's commit path (writes the browsed leaf into <c>TagConfig</c>), and the
|
||||
/// AdminUI typed tag editor (shows the inferred type and offers an override).
|
||||
/// This lives in <c>.Contracts</c> because every call site must agree byte-for-byte, or a
|
||||
/// tag's OPC UA type stops matching its authored config. At HEAD the callers are both inside
|
||||
/// the driver: <c>ITagDiscovery.DiscoverAsync</c> (stamps
|
||||
/// <see cref="DriverAttributeInfo.DriverDataType" /> on every browsed leaf) and
|
||||
/// <c>MTConnectDriver.InferDefinition</c> (the runtime coercion fallback for a raw tag whose
|
||||
/// blob declares no type).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ⚠️ <b>Two call sites this remark used to name do not exist.</b> The universal browser's
|
||||
/// commit path (<c>RawBrowseCommitMapper</c>) never calls this — it re-maps the browsed leaf's
|
||||
/// already-inferred <c>DriverDataType</c> onto the raw tag's OPC UA type but writes a
|
||||
/// <c>TagConfig</c> whose <c>dataType</c> is the model default — and the AdminUI typed editor
|
||||
/// (<c>MTConnectTagConfigModel</c>) deliberately does not recompute the inference at all. The
|
||||
/// first of those is a live defect, not merely a stale comment: see
|
||||
/// <c>code-reviews/Driver.MTConnect/findings.md</c> (Driver.MTConnect-003).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>MTConnect is weakly typed on the wire</b> — every observation is text, and the standard
|
||||
|
||||
@@ -86,7 +86,11 @@ public sealed class MTConnectDriverOptions
|
||||
/// type comes from the blob's <c>driverDataType</c>; failing that, from a
|
||||
/// <see cref="Tags"/> entry naming the same DataItem id; failing that, from the Agent's
|
||||
/// own <c>/probe</c> declaration (<see cref="MTConnectDataTypeInference"/>); failing
|
||||
/// that, <see cref="DriverDataType.String"/> — the coercion that cannot fail.</item>
|
||||
/// that, <see cref="DriverDataType.String"/> — the coercion that cannot fail.
|
||||
/// ⚠️ The <c>/probe</c> step is <b>not</b> reached by a browse-committed tag at HEAD:
|
||||
/// the AdminUI's commit mapper writes a full typed blob whose <c>dataType</c> defaults
|
||||
/// to <c>String</c>, which the first step then honours. See
|
||||
/// <c>code-reviews/Driver.MTConnect/findings.md</c> (Driver.MTConnect-003).</item>
|
||||
/// <item>A tag in <see cref="Tags"/> only is reachable by its DataItem <c>id</c>
|
||||
/// (the driver CLI's authoring surface, and every pre-v3 test) and still contributes
|
||||
/// its coercion type to the index.</item>
|
||||
|
||||
@@ -1224,7 +1224,7 @@ public sealed class MTConnectDriver
|
||||
// downstream). Its data items are unreachable either way.
|
||||
_logger.LogWarning(
|
||||
"MTConnect driver {DriverInstanceId} skipped a device in {AgentUri}'s /probe model that declares neither a name nor an id; it cannot be given a browse path.",
|
||||
_driverInstanceId, _options.AgentUri);
|
||||
_driverInstanceId, options.AgentUri);
|
||||
|
||||
continue;
|
||||
}
|
||||
@@ -1242,7 +1242,7 @@ public sealed class MTConnectDriver
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"MTConnect driver {DriverInstanceId} is scoped to device '{DeviceScope}', but {AgentUri}'s /probe model declares no device with that name or id ({DeclaredCount} declared). The browse is empty because the scope matched nothing, not because the Agent has nothing.",
|
||||
_driverInstanceId, deviceScope, _options.AgentUri, model.Devices.Count);
|
||||
_driverInstanceId, deviceScope, options.AgentUri, model.Devices.Count);
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -1252,7 +1252,7 @@ public sealed class MTConnectDriver
|
||||
_driverInstanceId,
|
||||
devices,
|
||||
variables,
|
||||
_options.AgentUri,
|
||||
options.AgentUri,
|
||||
deviceScope is null or "" ? string.Empty : $" (scoped to '{deviceScope}')");
|
||||
}
|
||||
|
||||
@@ -1854,10 +1854,13 @@ public sealed class MTConnectDriver
|
||||
/// "just re-initialize" would be a silent permanent deadlock.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Backoff applies to failures only.</b> A re-baseline is a protocol event, not a
|
||||
/// fault, so it reopens the stream immediately and resets the attempt count; only a
|
||||
/// genuine failure (dropped connection, blown heartbeat, a re-baseline that itself
|
||||
/// failed) advances <see cref="BackoffFor"/>.
|
||||
/// <b>Backoff applies to failures only — and to a re-baseline that resolved nothing.</b>
|
||||
/// A re-baseline behind a stream that delivered is a protocol event, not a fault, so it
|
||||
/// reopens immediately and resets the attempt count. One on a connection that delivered
|
||||
/// nothing advances <see cref="BackoffFor"/> like any other failure, because the
|
||||
/// <see cref="InvalidDataException"/> path that reaches it also carries conditions a
|
||||
/// <c>/current</c> cannot resolve (an unparseable chunk, an over-sized part) and would
|
||||
/// otherwise reopen-and-fail as fast as the Agent can answer.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task RunSampleStreamAsync(
|
||||
@@ -1927,8 +1930,21 @@ public sealed class MTConnectDriver
|
||||
return;
|
||||
|
||||
case StreamVerdict.Reopen:
|
||||
attempt = 0;
|
||||
_reconnectAttempts = 0;
|
||||
// A re-baseline behind a stream that DELIVERED is the protocol event this
|
||||
// verdict was written for — a sequence gap or an OUT_OF_RANGE rejection, both
|
||||
// of which the /current genuinely resolves — so it reopens immediately on a
|
||||
// clean ladder.
|
||||
//
|
||||
// One that delivered NOTHING is not necessarily resolved by anything. The
|
||||
// InvalidDataException catch that routes OUT_OF_RANGE here also catches an
|
||||
// unparseable chunk and an over-sized part, and a /current cannot fix either:
|
||||
// resetting the ladder there reopens instantly, fails identically, and spends
|
||||
// one /sample AND one /current per iteration forever — the unbounded hot loop
|
||||
// every other arm of this pump (the Unsupported latch, the backoff floor, the
|
||||
// non-positive cap) exists to prevent. The discriminator is the same one the
|
||||
// transient arm below uses, for the same reason.
|
||||
attempt = outcome.DeliveredAny ? 0 : attempt + 1;
|
||||
_reconnectAttempts = attempt;
|
||||
|
||||
break;
|
||||
|
||||
@@ -3208,10 +3224,22 @@ public sealed class MTConnectDriver
|
||||
/// <b>Coercion type precedence</b> (see <see cref="MTConnectDriverOptions.RawTags"/>):
|
||||
/// the blob's own <c>driverDataType</c>, else a <see cref="MTConnectDriverOptions.Tags"/>
|
||||
/// entry naming the same DataItem id, else the Agent's <c>/probe</c> declaration for that
|
||||
/// data item, else <see cref="DriverDataType.String"/>. The third step is what makes a
|
||||
/// browse-committed tag work: <c>RawBrowseCommitMapper</c> writes only the address into
|
||||
/// the blob, so without it every value from the address picker would publish as text
|
||||
/// under a numerically-typed OPC UA node.
|
||||
/// data item, else <see cref="DriverDataType.String"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ⚠️ <b>The third step does NOT currently rescue a browse-committed tag, and the
|
||||
/// AdminUI is where that has to be fixed.</b> It was written on the premise that
|
||||
/// <c>RawBrowseCommitMapper</c> writes only the address into the blob — true when this
|
||||
/// was authored, false at HEAD: the MTConnect branch of
|
||||
/// <c>RawBrowseCommitMapper.BuildTagConfig</c> now emits a whole
|
||||
/// <c>MTConnectTagConfigModel</c>, whose <c>ToJson</c> unconditionally writes
|
||||
/// <c>"dataType"</c> from a field defaulting to <see cref="DriverDataType.String"/>. That
|
||||
/// is a non-blank authored type, so step 1 wins and this step is never reached — every
|
||||
/// browse-committed tag coerces to text while its raw-tag node was typed <c>Double</c> /
|
||||
/// <c>Int64</c> from the SAME probe inference (<c>RawBrowseCommitMapper.MapLeaf</c>).
|
||||
/// The step is kept because it is still the correct rule and still serves a blob authored
|
||||
/// by hand, by CSV or by API with no type; see
|
||||
/// <c>code-reviews/Driver.MTConnect/findings.md</c> (Driver.MTConnect-003).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="options">The options document to derive from.</param>
|
||||
|
||||
@@ -16,19 +16,29 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||
/// <b>Deliberately does NOT delegate to <see cref="MTConnectDriver.ParseOptions"/>.</b> That
|
||||
/// method builds the full driver options document, including every authored
|
||||
/// <c>MTConnectTagDefinition</c> (<c>BuildTag</c> can throw on a malformed tag entry that has
|
||||
/// nothing to do with reachability). A probe only needs <c>agentUri</c>, so this type parses
|
||||
/// its own minimal DTO — a config that is invalid for the driver (bad tag, bad data type) can
|
||||
/// still be reachability-tested, matching the AdminUI's "does the endpoint answer" intent for
|
||||
/// Test Connect. It also avoids a compile-time dependency on the factory DTO shape
|
||||
/// (<c>MTConnectDriverFactoryExtensions</c>), which is authored separately (Task 15).
|
||||
/// nothing to do with reachability). A probe needs only the fields that compose the request
|
||||
/// URL, so this type parses its own minimal DTO — a config that is invalid for the driver (bad
|
||||
/// tag, bad data type) can still be reachability-tested, matching the AdminUI's "does the
|
||||
/// endpoint answer" intent for Test Connect. It also avoids a compile-time dependency on the
|
||||
/// factory DTO shape (<c>MTConnectDriverFactoryExtensions</c>), which is authored separately
|
||||
/// (Task 15).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Accepted gap: reachable ≠ deployable.</b> Because only <c>agentUri</c> is read, a
|
||||
/// config can show green on Test Connect and still fail to deploy — e.g. a non-positive
|
||||
/// <c>requestTimeoutMs</c> or a malformed tag entry the factory's full parse would reject.
|
||||
/// This is the direct consequence of the independent-parsing decision above, judged
|
||||
/// acceptable because Test Connect's job is "is the endpoint there", not "will this exact
|
||||
/// config deploy".
|
||||
/// <b>The minimal DTO is "everything in the URL", not "agentUri".</b> It reads
|
||||
/// <c>deviceName</c> too, because <see cref="MTConnectAgentClient"/> narrows every read to
|
||||
/// <c>{AgentUri}/{DeviceName}/probe</c>: probing the agent-wide path instead would answer a
|
||||
/// different question from the one the operator asked, and a mistyped or since-renamed device
|
||||
/// would show GREEN here and then Fault the driver at <c>InitializeAsync</c> (the Agent
|
||||
/// answers an unknown device path with an <c>MTConnectError</c> under HTTP 200). The
|
||||
/// distinction is not "which keys are cheap to read" but "which keys change the endpoint".
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Accepted gap: reachable ≠ deployable.</b> Because only the URL-composing fields are
|
||||
/// read, a config can show green on Test Connect and still fail to deploy — e.g. a
|
||||
/// non-positive <c>requestTimeoutMs</c> or a malformed tag entry the factory's full parse
|
||||
/// would reject. This is the direct consequence of the independent-parsing decision above,
|
||||
/// judged acceptable because Test Connect's job is "is the endpoint there", not "will this
|
||||
/// exact config deploy".
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Deliberately DOES reuse <see cref="MTConnectAgentClient"/> and
|
||||
@@ -117,6 +127,12 @@ public sealed class MTConnectDriverProbe : IDriverProbe
|
||||
private sealed class MTConnectProbeConfigDto
|
||||
{
|
||||
public string? AgentUri { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The optional device scope. Read because it is part of the <b>URL</b>, not part of the
|
||||
/// driver's tag/timing configuration — see the "reachable ≠ deployable" remarks on the type.
|
||||
/// </summary>
|
||||
public string? DeviceName { get; set; }
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -169,6 +185,11 @@ public sealed class MTConnectDriverProbe : IDriverProbe
|
||||
var options = new MTConnectDriverOptions
|
||||
{
|
||||
AgentUri = agentUri,
|
||||
|
||||
// Part of the request URL, so it must be carried: MTConnectAgentClient narrows every read
|
||||
// to {AgentUri}/{DeviceName}/probe, and a probe of the agent-wide path answers a different
|
||||
// question from the one the operator asked. See the type remarks.
|
||||
DeviceName = string.IsNullOrWhiteSpace(dto.DeviceName) ? null : dto.DeviceName.Trim(),
|
||||
RequestTimeoutMs = requestTimeoutMs,
|
||||
};
|
||||
|
||||
|
||||
@@ -720,6 +720,19 @@ internal sealed class MqttBrowseSession : IRebirthCapableBrowseSession
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether an operator-supplied scope segment may be used to <b>address</b> an NCMD. Everything
|
||||
/// <see cref="IsUsableLabel"/> requires, plus no MQTT wildcard: a group/edge-node id becomes a
|
||||
/// topic segment of the published command, and MQTT-3.3.2-2 forbids a wildcard in a PUBLISH
|
||||
/// topic. Kept separate from <see cref="IsUsableLabel"/> on purpose — that one also judges
|
||||
/// Sparkplug <i>metric</i> names, which are arbitrary publisher-chosen UTF-8 and are never part
|
||||
/// of a topic, so tightening it there would drop legitimate metrics from the tree.
|
||||
/// </summary>
|
||||
/// <param name="segment">The candidate scope segment.</param>
|
||||
/// <returns><see langword="true"/> when an NCMD may be addressed with it.</returns>
|
||||
private static bool IsUsableRebirthSegment(string segment) =>
|
||||
IsUsableLabel(segment) && segment.IndexOf('+') < 0 && segment.IndexOf('#') < 0;
|
||||
|
||||
/// <summary>Test seam over <see cref="Observe"/> that takes the payload as text.</summary>
|
||||
/// <param name="topic">The observed topic.</param>
|
||||
/// <param name="payload">The message body as text, or <see langword="null"/> for an empty payload.</param>
|
||||
@@ -895,7 +908,14 @@ internal sealed class MqttBrowseSession : IRebirthCapableBrowseSession
|
||||
}
|
||||
|
||||
// 3. A bare {group}/{edgeNode} the window has never seen — the prime rebirth target.
|
||||
if (segments.Length == 2 && IsUsableLabel(segments[0]) && IsUsableLabel(segments[1]))
|
||||
// ⚠ This is the ONE path that takes an id the session never parsed off the wire, so it is the
|
||||
// one that must check for a wildcard. IsUsableLabel deliberately permits '+'/'#' (a Sparkplug
|
||||
// METRIC name is arbitrary UTF-8 and could legitimately contain either), so it is not enough
|
||||
// here: `Plant1/#` would have assembled `spBv1.0/Plant1/NCMD/#`, a PUBLISH the MQTT spec
|
||||
// forbids, which a conformant broker answers by closing the browse connection — while `+`
|
||||
// would fan one operator click across every matching edge node. Cases 1 and 2 above cannot
|
||||
// reach this shape: their ids come off SparkplugTopic.TryParse, which rejects both characters.
|
||||
if (segments.Length == 2 && IsUsableRebirthSegment(segments[0]) && IsUsableRebirthSegment(segments[1]))
|
||||
{
|
||||
return [(segments[0], segments[1])];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
|
||||
|
||||
/// <summary>
|
||||
/// The two shape rules an authored MQTT address must satisfy to be <em>publishable</em>, shared by
|
||||
/// the deploy-time inspector (<see cref="MqttTagDefinitionFactory.Inspect"/>) and the write encoder
|
||||
/// (<see cref="MqttWriteEncoder"/>) so the warning an operator is shown and the refusal the driver
|
||||
/// makes can never disagree.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Neither rule gates the READ path, and that asymmetry is the point.</b> A tag whose
|
||||
/// subscription <c>topic</c> carries a wildcard still reads (ambiguously — hence
|
||||
/// <see cref="MqttTagDefinitionFactory.Inspect"/>'s long-standing advisory), and a Sparkplug tag
|
||||
/// with a malformed id still parses. What these rules decide is whether an <em>outbound</em>
|
||||
/// message could legally be addressed at all, so they belong to the write encoder and to the
|
||||
/// inspector, never to the mappers' accept gate — which must stay exactly as strict as the
|
||||
/// runtime reader (Gitea #551).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Why a wildcard write destination is a refusal rather than a best effort.</b> MQTT-3.3.2-2
|
||||
/// forbids a wildcard in a PUBLISH topic, and a conformant broker answers a protocol violation by
|
||||
/// closing the network connection — which for this driver is the one connection every
|
||||
/// subscription lives on. Publishing anyway would turn one mis-authored tag into a whole-driver
|
||||
/// outage; refusing costs that tag its write and nothing else.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class MqttAddressRules
|
||||
{
|
||||
/// <summary>The MQTT wildcard characters — illegal in any concrete (published or subscribed) topic.</summary>
|
||||
private static readonly char[] TopicWildcards = ['+', '#'];
|
||||
|
||||
/// <summary>
|
||||
/// Characters that cannot appear in a <b>single MQTT topic segment</b>, which is what a Sparkplug
|
||||
/// group / edge-node / device id is. <c>/</c> joins the wildcards here because it would silently
|
||||
/// re-shape the command topic into a different address rather than making it unpublishable — and
|
||||
/// because a decoded incoming id can never contain one, so an authored id that does is a binding
|
||||
/// that could never have resolved.
|
||||
/// </summary>
|
||||
private static readonly char[] SegmentIllegalChars = ['/', '+', '#'];
|
||||
|
||||
/// <summary>
|
||||
/// Whether <paramref name="topic"/> carries an MQTT wildcard (<c>+</c> / <c>#</c>).
|
||||
/// </summary>
|
||||
/// <param name="topic">A topic string; a <see langword="null"/>/empty one carries none.</param>
|
||||
/// <returns><see langword="true"/> when a wildcard is present.</returns>
|
||||
internal static bool HasWildcard(string? topic) =>
|
||||
!string.IsNullOrEmpty(topic) && topic.IndexOfAny(TopicWildcards) >= 0;
|
||||
|
||||
/// <summary>
|
||||
/// Whether <paramref name="id"/> is unusable as a single MQTT topic segment — i.e. as a Sparkplug
|
||||
/// group, edge-node or device id.
|
||||
/// </summary>
|
||||
/// <param name="id">The authored id; a <see langword="null"/>/empty one is not judged here (absence
|
||||
/// is handled by the mappers' own presence checks, and <c>deviceId</c> is legitimately absent).</param>
|
||||
/// <returns><see langword="true"/> when the id contains <c>/</c>, <c>+</c> or <c>#</c>.</returns>
|
||||
/// <remarks>
|
||||
/// Deliberately NOT applied to <c>metricName</c>: a metric name rides in the Sparkplug
|
||||
/// <em>payload</em>, not in the topic, and Sparkplug's own canonical names use <c>/</c> (e.g.
|
||||
/// <c>Node Control/Rebirth</c>). Applying it there would refuse every conformant node-control tag.
|
||||
/// </remarks>
|
||||
internal static bool IsIllegalTopicSegment(string? id) =>
|
||||
!string.IsNullOrEmpty(id) && id.IndexOfAny(SegmentIllegalChars) >= 0;
|
||||
}
|
||||
@@ -47,6 +47,14 @@ public static class MqttTagDefinitionFactory
|
||||
/// <summary>The MQTT wildcard characters — illegal in a <em>tag's</em> (concrete) subscription topic.</summary>
|
||||
private static readonly char[] TopicWildcards = ['+', '#'];
|
||||
|
||||
/// <summary>
|
||||
/// The Sparkplug address keys that are single MQTT topic segments, and so cannot carry <c>/</c>,
|
||||
/// <c>+</c> or <c>#</c>. <see cref="MqttTagConfigKeys.MetricName"/> is deliberately absent — it
|
||||
/// rides in the payload, not the topic. See <see cref="MqttAddressRules.IsIllegalTopicSegment"/>.
|
||||
/// </summary>
|
||||
private static readonly string[] SparkplugSegmentKeys =
|
||||
[MqttTagConfigKeys.GroupId, MqttTagConfigKeys.EdgeNodeId, MqttTagConfigKeys.DeviceId];
|
||||
|
||||
/// <summary>
|
||||
/// Maps an authored <c>TagConfig</c> object to a typed definition keyed by <paramref name="rawPath"/>.
|
||||
/// The input is always an authored TagConfig JSON object (there is no name-vs-blob heuristic in v3),
|
||||
@@ -265,9 +273,13 @@ public static class MqttTagDefinitionFactory
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <b>Advisories</b> — a <b>wildcard</b> tag topic (<c>+</c> / <c>#</c>), which the mapper
|
||||
/// deliberately ACCEPTS (ambiguous, not unparseable) so this runs either way; and a
|
||||
/// present-but-invalid <c>payloadFormat</c> on an ACCEPTED blob, which can only mean the
|
||||
/// Sparkplug reader took it and the Plain reader would not (Gitea #557 item 2).
|
||||
/// deliberately ACCEPTS (ambiguous, not unparseable) so this runs either way; a wildcard
|
||||
/// <c>writeTopic</c>, which reads fine but can never be published to, so every write is
|
||||
/// refused <c>BadNotWritable</c>; a Sparkplug <c>groupId</c>/<c>edgeNodeId</c>/
|
||||
/// <c>deviceId</c> carrying a character no MQTT topic segment can hold, which makes the
|
||||
/// tag permanently unbindable; and a present-but-invalid <c>payloadFormat</c> on an
|
||||
/// ACCEPTED blob, which can only mean the Sparkplug reader took it and the Plain reader
|
||||
/// would not (Gitea #557 item 2).
|
||||
/// </item>
|
||||
/// </list>
|
||||
/// Empty for a clean, readable blob, for a blank one, or for one that is not an equipment-tag
|
||||
@@ -370,6 +382,36 @@ public static class MqttTagDefinitionFactory
|
||||
"concrete, or the tag will be fed by every matching topic.");
|
||||
}
|
||||
|
||||
// The write destination gets the same treatment as the read topic, and for a sharper reason: a
|
||||
// wildcard cannot be PUBLISHED to at all (MQTT-3.3.2-2), so the tag reads perfectly while every
|
||||
// write is refused with BadNotWritable — a split nothing else surfaces. The AdminUI's typed
|
||||
// editor already refuses this, but it is only ONE authoring path (the raw-JSON textarea, CSV
|
||||
// import and the API all reach the deploy gate directly), so leaving it out here made the
|
||||
// authoring surface stricter than the deploy gate — the drift this inspector exists to close.
|
||||
var writeTopic = ReadString(root, MqttTagConfigKeys.WriteTopic);
|
||||
if (MqttAddressRules.HasWildcard(writeTopic))
|
||||
{
|
||||
advisories.Add(
|
||||
$"value '{writeTopic}' for 'writeTopic' contains an MQTT wildcard (+ or #); a wildcard "
|
||||
+ "cannot be published to, so the tag reads normally but every write is refused "
|
||||
+ "(BadNotWritable).");
|
||||
}
|
||||
|
||||
// Same shape, Sparkplug side: a group/edge-node/device id is a single MQTT topic segment, so a
|
||||
// decoded incoming id can never contain '/', '+' or '#'. An authored one that does is a binding
|
||||
// that can never resolve — the mapper accepts it (it checks presence, not shape), so this is the
|
||||
// only place it can be said. `metricName` is deliberately exempt: it rides in the payload, and
|
||||
// Sparkplug's own canonical names use '/'.
|
||||
foreach (var key in SparkplugSegmentKeys)
|
||||
{
|
||||
var value = ReadString(root, key);
|
||||
if (!MqttAddressRules.IsIllegalTopicSegment(value)) continue;
|
||||
advisories.Add(
|
||||
$"value '{value}' for '{key}' contains a character ('/', '+' or '#') that cannot appear "
|
||||
+ "in an MQTT topic segment; a decoded Sparkplug id never contains one, so this tag can "
|
||||
+ "never bind and will read BadNodeIdUnknown in Sparkplug B mode.");
|
||||
}
|
||||
|
||||
// Gitea #557 item 2 — the ONE key whose verdict depends on the DRIVER'S mode, reported as an
|
||||
// advisory because no tag-level gate can see that mode. A present-but-invalid payloadFormat is
|
||||
// fatal to the Plain parse and invisible to the Sparkplug one, so a union accept here can only
|
||||
|
||||
@@ -19,9 +19,11 @@ public enum MqttWriteEncodeError
|
||||
None = 0,
|
||||
|
||||
/// <summary>
|
||||
/// The tag declares no write destination: a Plain tag with no <c>writeTopic</c>. Deliberately a
|
||||
/// hard refusal — see <see cref="MqttWriteEncoder"/>'s remarks on why a silent no-op would be
|
||||
/// worse than the honest read-only behaviour this replaced.
|
||||
/// The tag declares no <b>usable</b> write destination: a Plain tag with no <c>writeTopic</c> or
|
||||
/// one carrying an MQTT wildcard, or a Sparkplug tag whose binding tuple is incomplete or whose
|
||||
/// group/edge-node/device id is not a legal topic segment (see <see cref="MqttAddressRules"/>).
|
||||
/// Deliberately a hard refusal — see <see cref="MqttWriteEncoder"/>'s remarks on why a silent
|
||||
/// no-op would be worse than the honest read-only behaviour this replaced.
|
||||
/// </summary>
|
||||
NoWriteDestination,
|
||||
|
||||
@@ -121,6 +123,17 @@ public static class MqttWriteEncoder
|
||||
return MqttWriteEncodeError.NoWriteDestination;
|
||||
}
|
||||
|
||||
// A wildcard topic is not a destination — MQTT-3.3.2-2 forbids one in a PUBLISH, and a
|
||||
// conformant broker answers a protocol violation by CLOSING the connection, which is the same
|
||||
// connection every one of this driver's subscriptions lives on. So publishing anyway would turn
|
||||
// one mis-authored tag into a whole-driver outage. `NoWriteDestination` (→ BadNotWritable) is the
|
||||
// right verdict rather than a new error: the tag genuinely has nowhere a write could be sent, and
|
||||
// the read path is untouched. See MqttAddressRules.
|
||||
if (MqttAddressRules.HasWildcard(def.WriteTopic))
|
||||
{
|
||||
return MqttWriteEncodeError.NoWriteDestination;
|
||||
}
|
||||
|
||||
if (value is null)
|
||||
{
|
||||
// No MQTT payload means "the value is null" — a zero-length payload is a legitimate
|
||||
@@ -175,6 +188,18 @@ public static class MqttWriteEncoder
|
||||
return MqttWriteEncodeError.NoWriteDestination;
|
||||
}
|
||||
|
||||
// …and an id that is not a legal topic SEGMENT is the same failure by a different route: the
|
||||
// tuple is assembled into the NCMD/DCMD topic, so a '/' would silently address a different node
|
||||
// than the tag names while '+'/'#' would make the PUBLISH a protocol violation (see the Plain
|
||||
// branch). `metricName` is exempt on purpose — it rides in the payload, and Sparkplug's own
|
||||
// canonical names use '/'.
|
||||
if (MqttAddressRules.IsIllegalTopicSegment(groupId)
|
||||
|| MqttAddressRules.IsIllegalTopicSegment(edgeNodeId)
|
||||
|| MqttAddressRules.IsIllegalTopicSegment(def.DeviceId))
|
||||
{
|
||||
return MqttWriteEncodeError.NoWriteDestination;
|
||||
}
|
||||
|
||||
if (value is null)
|
||||
{
|
||||
return MqttWriteEncodeError.ValueNotRepresentable;
|
||||
|
||||
@@ -119,6 +119,21 @@ public static class MqttClientOptionsFactory
|
||||
|
||||
if (options.AllowUntrustedServerCertificate)
|
||||
{
|
||||
// A configured CA pin is DEAD in this branch — every certificate is accepted, pinned chain
|
||||
// or not. Silence here reads to an operator as "the pin is in force", which is the one
|
||||
// belief this combination must never leave them holding: the pin is what they set to make a
|
||||
// MITM fail closed, and the escape hatch is what makes it fail open. Warn, do not
|
||||
// second-guess — the explicit flag stays authoritative, so a deployment that genuinely wants
|
||||
// the hatch keeps working and merely says so in its log.
|
||||
if (!string.IsNullOrWhiteSpace(options.CaCertificatePath))
|
||||
{
|
||||
logger?.LogWarning(
|
||||
"MQTT: allowUntrustedServerCertificate is on, so the pinned CA '{CaCertificatePath}' is NOT "
|
||||
+ "being enforced — ANY broker certificate is accepted. Clear the flag to make the pin "
|
||||
+ "effective.",
|
||||
options.CaCertificatePath);
|
||||
}
|
||||
|
||||
// Dev / on-prem escape hatch, off unless explicitly enabled — mirrors the
|
||||
// ServerHistorian TLS knobs. This is the ONLY branch that accepts a bad certificate.
|
||||
tls.WithAllowUntrustedCertificates(true)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// System.Buffers is load-bearing despite no type here naming it: OnApplicationMessageReceivedAsync
|
||||
// calls ReadOnlySequence<byte>.ToArray(), which is BuffersExtensions', not a member.
|
||||
using System.Buffers;
|
||||
using System.Net.Security;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MQTTnet;
|
||||
using MQTTnet.Protocol;
|
||||
@@ -238,13 +237,14 @@ public sealed class MqttConnectRejectedException : Exception
|
||||
/// client it created and reports <see cref="ObjectDisposedException"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The publish leg is deliberately narrow.</b> <see cref="PublishAsync"/> exists to satisfy
|
||||
/// <see cref="Sparkplug.IMqttPublishTransport"/> — whose entire production caller is
|
||||
/// <see cref="Sparkplug.RebirthRequester"/> — not to make this a general MQTT publisher. This
|
||||
/// driver has no <c>IWritable</c> leg in v1, so a Sparkplug rebirth NCMD is the <i>only</i>
|
||||
/// outbound application message it ever sends. Sparkplug rebirth-on-reconnect itself is decided
|
||||
/// by <see cref="Sparkplug.SparkplugIngestor"/> hanging off <see cref="Reconnected"/>; this type
|
||||
/// only carries the bytes.
|
||||
/// <b>The publish leg is deliberately narrow.</b> <see cref="PublishAsync"/> exists to carry
|
||||
/// outbound application messages for callers that decide <i>what</i> to send, not to make this a
|
||||
/// general MQTT publisher: <see cref="Sparkplug.IMqttPublishTransport"/> (implemented for
|
||||
/// <see cref="Sparkplug.RebirthRequester"/>'s NCMDs), <c>MqttDriver.WriteAsync</c>'s encoded
|
||||
/// write (Gitea #508 — the statement that this driver has no <c>IWritable</c> leg was true only
|
||||
/// before it) and <see cref="Sparkplug.SparkplugPrimaryHost"/>'s retained STATE. Sparkplug
|
||||
/// rebirth-on-reconnect itself is decided by <see cref="Sparkplug.SparkplugIngestor"/> hanging
|
||||
/// off <see cref="Reconnected"/>; this type only carries the bytes.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class MqttConnection : IAsyncDisposable, IMqttSubscribeTransport, Sparkplug.IMqttPublishTransport
|
||||
|
||||
@@ -104,6 +104,18 @@ public sealed class MqttDriver
|
||||
/// <summary>How often the host-connectivity poll samples <see cref="MqttConnection.State"/>.</summary>
|
||||
private static readonly TimeSpan HostProbeInterval = TimeSpan.FromSeconds(1);
|
||||
|
||||
/// <summary>
|
||||
/// Stand-in for a non-positive authored <c>connectTimeoutSeconds</c> — see <see cref="Normalize"/>.
|
||||
/// Matches <see cref="MqttDriverOptions.ConnectTimeoutSeconds"/>'s own default.
|
||||
/// </summary>
|
||||
private const int DefaultConnectTimeoutSeconds = 15;
|
||||
|
||||
/// <summary>
|
||||
/// Stand-in for a negative authored <c>keepAliveSeconds</c> — see <see cref="Normalize"/>.
|
||||
/// Matches <see cref="MqttDriverOptions.KeepAliveSeconds"/>'s own default.
|
||||
/// </summary>
|
||||
private const int DefaultKeepAliveSeconds = 30;
|
||||
|
||||
private readonly string _driverInstanceId;
|
||||
private readonly ILogger? _logger;
|
||||
|
||||
@@ -221,13 +233,13 @@ public sealed class MqttDriver
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||
|
||||
_options = options;
|
||||
_options = Normalize(options);
|
||||
_driverInstanceId = driverInstanceId;
|
||||
_logger = logger;
|
||||
_primaryRoleSource = primaryRoleSource ?? UnmanagedDriverPrimaryRoleSource.Instance;
|
||||
_subscriptions = CreateSubscriptionManager(options);
|
||||
_sparkplug = CreateSparkplugIngestor(options);
|
||||
RegisterAuthoredTags(options);
|
||||
_subscriptions = CreateSubscriptionManager(_options);
|
||||
_sparkplug = CreateSparkplugIngestor(_options);
|
||||
RegisterAuthoredTags(_options);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -372,7 +384,12 @@ public sealed class MqttDriver
|
||||
/// <inheritdoc />
|
||||
public async Task ShutdownAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var lastMessage = ReadHealth().LastSuccessfulRead;
|
||||
// GetHealth(), not ReadHealth(): the live connection's LastMessageUtc is the real last-message
|
||||
// instant, and the stored snapshot only ever carries what was true at the last initialize. Taking
|
||||
// the stored one here throws away the evidence a shutdown-then-initialize cycle (the #527
|
||||
// watchdog's reconnect) is measured against, reporting a driver that was delivering a moment ago
|
||||
// as one that has never delivered.
|
||||
var lastMessage = GetHealth().LastSuccessfulRead;
|
||||
if (await _lifecycleGate.WaitAsync(TimeSpan.FromSeconds(_options.ConnectTimeoutSeconds), cancellationToken)
|
||||
.ConfigureAwait(false))
|
||||
{
|
||||
@@ -932,6 +949,17 @@ public sealed class MqttDriver
|
||||
WriteHealth(new DriverHealth(DriverState.Initializing, ReadHealth().LastSuccessfulRead, null));
|
||||
try
|
||||
{
|
||||
// Tear down whatever this driver already holds BEFORE building a new session. Both fields
|
||||
// published below (_connection, _primaryHost) are IAsyncDisposable and were previously
|
||||
// overwritten, so a second initialize on a live driver stranded a connected, supervised
|
||||
// broker session — plus a whole second primary-host connection — that no later dispose
|
||||
// could reach. Reachable without any teardown of ours: DriverInstanceActor re-runs
|
||||
// InitializeAsync with no ShutdownAsync when an ApplyDelta lands while the driver is in
|
||||
// Connecting/Reconnecting (AdoptConfigDuringInit). Idempotent, so the ReinitializeAsync
|
||||
// rebuild branch calling TeardownAsync first costs a no-op, and a first initialize sees
|
||||
// three nulls.
|
||||
await TeardownAsync().ConfigureAwait(false);
|
||||
|
||||
// A blank / unusable blob keeps the constructor's options rather than degrading the
|
||||
// driver to defaults pointing at localhost.
|
||||
var parsed = ParseOptions(driverConfigJson);
|
||||
@@ -1030,7 +1058,8 @@ public sealed class MqttDriver
|
||||
// deliberately: enums must round-trip by NAME. A second, divergent
|
||||
// JsonSerializerOptions is this repo's documented systemic enum bug, where an
|
||||
// AdminUI-authored config with a string enum field faults the driver.
|
||||
return JsonSerializer.Deserialize<MqttDriverOptions>(driverConfigJson, MqttJson.Options);
|
||||
var parsed = JsonSerializer.Deserialize<MqttDriverOptions>(driverConfigJson, MqttJson.Options);
|
||||
return parsed is null ? null : Normalize(parsed);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
@@ -1050,6 +1079,54 @@ public sealed class MqttDriver
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replaces out-of-range duration knobs with their documented defaults, once, at the single seam
|
||||
/// every options instance this driver holds passes through (the constructor and
|
||||
/// <see cref="ParseOptions"/>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <see cref="MqttDriverOptions"/> carries <c>[Range]</c> annotations, but nothing validates
|
||||
/// them on the runtime path: the blob is deserialized straight out of the deploy artifact, so
|
||||
/// a config authored (or hand-edited) with <c>connectTimeoutSeconds: 0</c> or a negative value
|
||||
/// reaches the driver unchecked. Both are load-bearing, and neither fails where an operator
|
||||
/// would look:
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item>
|
||||
/// zero makes every <c>new CancellationTokenSource(TimeSpan.Zero)</c> in the connect,
|
||||
/// subscribe and publish paths fire immediately, so the driver can never connect and
|
||||
/// reports a timeout against a perfectly healthy broker;
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// a negative value is outside <see cref="SemaphoreSlim.WaitAsync(TimeSpan)"/>'s and
|
||||
/// <see cref="CancellationTokenSource"/>'s legal range, so it throws
|
||||
/// <see cref="ArgumentOutOfRangeException"/> out of <see cref="ShutdownAsync"/> and
|
||||
/// <see cref="DisposeAsync"/> — the two paths whose whole job is to not fail.
|
||||
/// </item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// Normalising here rather than at each use site keeps one answer for the whole driver: the
|
||||
/// value also rides into <see cref="MqttConnection"/>, <see cref="SparkplugPrimaryHost"/> and
|
||||
/// <c>MqttClientOptionsFactory</c> through <see cref="_options"/>. <c>MaxPayloadBytes</c> and
|
||||
/// the reconnect backoffs are deliberately left alone — the manager's constructor and
|
||||
/// <see cref="MqttConnection.NextBackoff"/> already clamp those at their own use site.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static MqttDriverOptions Normalize(MqttDriverOptions options)
|
||||
{
|
||||
var connectTimeout = options.ConnectTimeoutSeconds > 0
|
||||
? options.ConnectTimeoutSeconds
|
||||
: DefaultConnectTimeoutSeconds;
|
||||
|
||||
// Zero keep-alive is legal MQTT ("no keep-alive"); only a negative one is nonsense.
|
||||
var keepAlive = options.KeepAliveSeconds >= 0 ? options.KeepAliveSeconds : DefaultKeepAliveSeconds;
|
||||
|
||||
return connectTimeout == options.ConnectTimeoutSeconds && keepAlive == options.KeepAliveSeconds
|
||||
? options
|
||||
: options with { ConnectTimeoutSeconds = connectTimeout, KeepAliveSeconds = keepAlive };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adopts new options, rebuilding the subscription manager only when a setting it captures at
|
||||
/// construction actually changed. The rebuilt manager inherits the driver-owned
|
||||
|
||||
@@ -183,17 +183,35 @@ public sealed class MqttSubscriptionManager
|
||||
/// transport.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The manager does not own the connection's lifetime and never detaches — the driver disposes
|
||||
/// both together. Call once per connection, and <b>before</b> the first
|
||||
/// <see cref="SubscribeAsync"/>: a subscribe issued with no transport attached records its
|
||||
/// intent (and warns) but sends nothing to a broker, and would only be established if the
|
||||
/// connection later dropped and recovered.
|
||||
/// <para>
|
||||
/// The manager does not own the connection's lifetime and never detaches — the driver disposes
|
||||
/// both together. Call once per connection, and <b>before</b> the first
|
||||
/// <see cref="SubscribeAsync"/>: a subscribe issued with no transport attached records its
|
||||
/// intent (and warns) but sends nothing to a broker, and would only be established if the
|
||||
/// connection later dropped and recovered.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Binding to a session forgets the previous session's established filters</b> — the same
|
||||
/// reset <see cref="Sparkplug.SparkplugIngestor.AttachTo"/> performs, and for the same reason:
|
||||
/// this manager <i>outlives</i> a session rebuild. <c>MqttDriver.AdoptOptions</c> only
|
||||
/// replaces it when the ingest identity changed, so a driver-host reconnect (ShutdownAsync +
|
||||
/// InitializeAsync, #527's watchdog path) and a <c>ReinitializeAsync</c> that changes only the
|
||||
/// endpoint/credentials both re-attach <i>this</i> instance to a brand-new broker session.
|
||||
/// <c>_subscribedTopics</c> is a claim about the session that has just gone: carried across,
|
||||
/// the host's post-reconnect re-subscribe resolves an empty pending set, issues no SUBSCRIBE
|
||||
/// at all, and leaves a driver that reports Connected/Healthy and receives nothing until the
|
||||
/// <i>new</i> session happens to drop — the silent-death outcome every other path in this
|
||||
/// type is written to prevent.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="connection">The connection to observe.</param>
|
||||
public void AttachTo(MqttConnection connection)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(connection);
|
||||
|
||||
// FIRST, before delivery is wired: a new session holds none of the old one's subscriptions.
|
||||
_subscribedTopics.Clear();
|
||||
|
||||
_transport = connection;
|
||||
connection.MessageReceived += HandleMessage;
|
||||
connection.Reconnected += OnReconnectedAsync;
|
||||
|
||||
@@ -285,11 +285,19 @@ public sealed class SequenceTracker
|
||||
/// session token.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The reconnect seam (§3.6 invariant #5 — late join). After a dropped connection the driver
|
||||
/// has missed an unknown number of messages, so the baseline it was holding is no longer
|
||||
/// evidence of anything: carrying it across would let the stream look contiguous purely
|
||||
/// because the edge node happened to publish the right number of messages while the driver was
|
||||
/// away.
|
||||
/// <para>
|
||||
/// The rationale is §3.6 invariant #5 (late join): after a dropped connection the driver has
|
||||
/// missed an unknown number of messages, so the baseline it was holding is no longer evidence
|
||||
/// of anything — carrying it across would let the stream look contiguous purely because the
|
||||
/// edge node happened to publish the right number of messages while the driver was away.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The reconnect path does not call this</b>, despite the reasoning being its.
|
||||
/// <c>SparkplugIngestor.ResetSessionState</c> drops the whole per-node tracker <i>map</i>
|
||||
/// instead, which additionally releases the entry for a node the plant has stopped
|
||||
/// publishing. This member is the same reset for a single tracker, kept for a caller holding
|
||||
/// one directly (the unit suite is currently the only one).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public void Reset()
|
||||
{
|
||||
|
||||
@@ -74,7 +74,7 @@ internal static class SqlCatalogLoader
|
||||
"the catalog views, which is a grant problem rather than a tag-authoring one");
|
||||
|
||||
var defaultSchema = await ReadDefaultSchemaAsync(
|
||||
connection, dialect, timeoutSeconds, cancellationToken).ConfigureAwait(false);
|
||||
connection, dialect, schemas, timeoutSeconds, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var tablesBySchema = new Dictionary<string, IReadOnlyList<string>>(StringComparer.Ordinal);
|
||||
var columnsByTable = new Dictionary<string, IReadOnlyList<string>>(StringComparer.Ordinal);
|
||||
@@ -132,12 +132,23 @@ internal static class SqlCatalogLoader
|
||||
/// query answers null/blank.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A null answer is possible (a login with no default schema mapped). Falling back to the single
|
||||
/// visible schema keeps the common one-schema database working; with several visible schemas there is
|
||||
/// no defensible guess, so unqualified names simply will not resolve and the gate says so by name.
|
||||
/// <para>A null answer is possible (a login with no default schema mapped, or a Postgres connection
|
||||
/// whose <c>search_path</c> resolves to nothing). Falling back to the single visible schema keeps the
|
||||
/// common one-schema database working; with several visible schemas there is no defensible guess, so
|
||||
/// unqualified names simply will not resolve and the gate says so by name.</para>
|
||||
/// <para><b>The fallback is load-bearing, not cosmetic.</b> Without it a blank answer resolves the
|
||||
/// default schema to the empty string, the tables query runs for schema <c>''</c>, it matches nothing,
|
||||
/// and <em>every unqualified tag on the driver</em> is rejected by the gate and reads
|
||||
/// <c>BadNodeIdUnknown</c> — a whole-driver outage against a healthy database with every tag correctly
|
||||
/// authored. Guessing among several schemas is the opposite error (a tag silently bound to the wrong
|
||||
/// relation), which is why the fallback stops at exactly one candidate.</para>
|
||||
/// </remarks>
|
||||
private static async Task<string> ReadDefaultSchemaAsync(
|
||||
DbConnection connection, ISqlDialect dialect, int timeoutSeconds, CancellationToken cancellationToken)
|
||||
DbConnection connection,
|
||||
ISqlDialect dialect,
|
||||
IReadOnlyList<string> schemas,
|
||||
int timeoutSeconds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var command = connection.CreateCommand();
|
||||
await using (command.ConfigureAwait(false))
|
||||
@@ -147,7 +158,11 @@ internal static class SqlCatalogLoader
|
||||
command.CommandTimeout = timeoutSeconds;
|
||||
var value = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
var schema = value is null or DBNull ? null : value.ToString();
|
||||
return string.IsNullOrWhiteSpace(schema) ? string.Empty : schema;
|
||||
if (!string.IsNullOrWhiteSpace(schema)) return schema;
|
||||
|
||||
// No default schema for this login: the sole visible schema is the only defensible answer, and
|
||||
// with more than one there is none — the empty string then resolves nothing, by design.
|
||||
return schemas.Count == 1 ? schemas[0] : string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,11 @@
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect\ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect.csproj"/>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" />
|
||||
<!-- No provider package here on purpose: every type in this project speaks System.Data.Common
|
||||
only (DbProviderFactory / DbConnection / DbCommand), and the concrete provider packages live
|
||||
behind the .Dialect leaf referenced above. A direct Microsoft.Data.SqlClient reference sat
|
||||
here unused after that extraction and read as "this project is SQL-Server-specific", which
|
||||
is exactly the assumption the dialect seam exists to prevent. -->
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="CalculationDriverProbe"/> — the Calculation pseudo-driver's Test-Connect
|
||||
/// probe (parse-only, since there is no backend to reach).
|
||||
/// </summary>
|
||||
public sealed class CalculationDriverProbeTests
|
||||
{
|
||||
private static readonly CalculationDriverProbe Sut = new();
|
||||
|
||||
[Fact]
|
||||
public async Task Empty_config_json_fails()
|
||||
{
|
||||
var result = await Sut.ProbeAsync("", TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Message.ShouldNotBeNullOrWhiteSpace();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Malformed_json_fails()
|
||||
{
|
||||
var result = await Sut.ProbeAsync("not json", TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Message.ShouldNotBeNullOrWhiteSpace();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_config_with_no_rawTags_array_is_still_valid()
|
||||
{
|
||||
var result = await Sut.ProbeAsync("{}", TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
result.Ok.ShouldBeTrue(result.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_well_formed_rawTags_config_is_valid()
|
||||
{
|
||||
const string json = """{"rawTags":[{"rawPath":"calc/e/x","tagConfig":"{}","writeIdempotent":false}]}""";
|
||||
var result = await Sut.ProbeAsync(json, TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
result.Ok.ShouldBeTrue(result.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The probe must not be STRICTER than the real spawn/reinit path: both
|
||||
/// <c>CalculationDriverFactoryExtensions.JsonOptions</c> and <c>CalculationDriver.ReinitJsonOptions</c>
|
||||
/// set <c>AllowTrailingCommas</c> + <c>ReadCommentHandling.Skip</c>. Before this pin the probe's own
|
||||
/// options omitted both, so a config a real deploy would accept could fail Test-Connect as "invalid".
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Tolerates_trailing_commas_like_the_real_spawn_and_reinit_paths()
|
||||
{
|
||||
const string json = """{"rawTags":[{"rawPath":"calc/e/x","tagConfig":"{}","writeIdempotent":false},],}""";
|
||||
var result = await Sut.ProbeAsync(json, TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
result.Ok.ShouldBeTrue(result.Message);
|
||||
}
|
||||
|
||||
/// <summary>Same parity claim for comments, which the spawn/reinit path also tolerates.</summary>
|
||||
[Fact]
|
||||
public async Task Tolerates_comments_like_the_real_spawn_and_reinit_paths()
|
||||
{
|
||||
const string json = """
|
||||
{
|
||||
// a hand-edited config comment
|
||||
"rawTags": []
|
||||
}
|
||||
""";
|
||||
var result = await Sut.ProbeAsync(json, TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
result.Ok.ShouldBeTrue(result.Message);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Serilog;
|
||||
using Serilog.Events;
|
||||
using Shouldly;
|
||||
@@ -74,6 +75,27 @@ public sealed class CalculationDriverTests
|
||||
e.Snapshot.StatusCode.ShouldBe(StatusGood);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The change-gate must hold BEFORE the script ever runs, not just before the publish. A
|
||||
/// multi-dependency script that casts a missing dep's <c>.Value</c> to a non-nullable type (the
|
||||
/// common idiom used throughout this file) throws when evaluated against a partial snapshot, so
|
||||
/// evaluating anyway during ordinary warm-up produced a spurious "script threw" script-log Warning
|
||||
/// on every partial dependency arrival — even though nothing is actually wrong with the script.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task No_evaluation_or_script_log_while_a_declared_dep_is_still_missing()
|
||||
{
|
||||
var (driver, published, logs) = Build(
|
||||
CalcTag("calc/e/sum", "return (double)ctx.GetTag(\"src/a\").Value + (double)ctx.GetTag(\"src/b\").Value;"));
|
||||
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
driver.OnDependencyValue("src/a", 10.0, StatusGood, DateTime.UtcNow);
|
||||
|
||||
published.ShouldBeEmpty();
|
||||
logs.ShouldBeEmpty("a still-missing declared dependency must hold the gate BEFORE evaluation, " +
|
||||
"not merely before the publish");
|
||||
}
|
||||
|
||||
// ── dedup + recompute ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
@@ -209,6 +231,102 @@ public sealed class CalculationDriverTests
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Unknown);
|
||||
}
|
||||
|
||||
// ── evaluator diagnostics reach the driver's own logger ──────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="CalculationEvaluator"/> the driver constructs internally must log its host-side
|
||||
/// diagnostics (compile errors, sandbox violations, execution throws) through the SAME
|
||||
/// <c>ILogger<CalculationDriver></c> the driver itself was given — not silently into a
|
||||
/// no-op logger. A timer-triggered tag with a deliberately broken script is the vehicle: it
|
||||
/// evaluates on its own cadence with no dependency needed to arrive first.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Evaluator_compile_diagnostics_flow_through_to_the_drivers_own_logger()
|
||||
{
|
||||
var logger = new CapturingLogger();
|
||||
var scriptRoot = new ScriptRootLogger(new LoggerConfiguration().CreateLogger());
|
||||
var driver = new CalculationDriver(
|
||||
new CalculationDriverOptions
|
||||
{
|
||||
RawTags = [CalcTag("calc/e/bad", "this is not valid C#;", changeTriggered: false, timerMs: 50)],
|
||||
},
|
||||
"calc-drv", scriptRoot, logger);
|
||||
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var fired = await WaitUntil(
|
||||
() => logger.Entries.Any(e => e.Level == LogLevel.Warning && e.Message.Contains("Roslyn compile failed")),
|
||||
TimeSpan.FromSeconds(3));
|
||||
|
||||
fired.ShouldBeTrue("the evaluator's compile-failure diagnostic should reach the driver-supplied logger");
|
||||
|
||||
await driver.ShutdownAsync(TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
// ── skipped-tag reporting (Gitea #541) ────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkippedTags_reports_a_raw_tag_with_no_scriptId_alongside_the_authored_total()
|
||||
{
|
||||
var noScriptId = new RawTagEntry("calc/e/nogood", "{\"foo\":\"bar\"}", WriteIdempotent: false);
|
||||
var good = CalcTag("calc/e/good", "return 1.0;", changeTriggered: false);
|
||||
var (driver, _, _) = Build(noScriptId, good);
|
||||
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var report = driver.GetSkippedTags();
|
||||
|
||||
report.ShouldNotBeNull();
|
||||
report!.AuthoredCount.ShouldBe(2);
|
||||
report.SkippedCount.ShouldBe(1);
|
||||
report.Sample.ShouldContain(s => s.RawPath == "calc/e/nogood");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkippedTags_has_no_skips_when_every_authored_tag_maps()
|
||||
{
|
||||
var (driver, _, _) = Build(CalcTag("calc/e/good", "return 1.0;", changeTriggered: false));
|
||||
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var report = driver.GetSkippedTags();
|
||||
|
||||
report.ShouldNotBeNull();
|
||||
report!.AuthoredCount.ShouldBe(1);
|
||||
report.SkippedCount.ShouldBe(0);
|
||||
report.HasSkips.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>The skip ledger must be REBUILT on ReinitializeAsync, not just at construction — an
|
||||
/// ApplyDelta that drops a tag's scriptId (or adds a new tag with none) must be reflected immediately,
|
||||
/// since RebuildTagTable is the one routine both the ctor and reinit share.</summary>
|
||||
[Fact]
|
||||
public async Task GetSkippedTags_updates_after_reinitialize()
|
||||
{
|
||||
var (driver, _, _) = Build(CalcTag("calc/e/x", "return 1.0;", changeTriggered: false));
|
||||
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
driver.GetSkippedTags()!.SkippedCount.ShouldBe(0);
|
||||
|
||||
const string reinitJson = """{"rawTags":[{"rawPath":"calc/e/x","tagConfig":"{}","writeIdempotent":false}]}""";
|
||||
await driver.ReinitializeAsync(reinitJson, TestContext.Current.CancellationToken);
|
||||
|
||||
var report = driver.GetSkippedTags();
|
||||
report!.AuthoredCount.ShouldBe(1);
|
||||
report.SkippedCount.ShouldBe(1, "the reinit payload's tag has no scriptId, so it is no longer a calc tag");
|
||||
}
|
||||
|
||||
/// <summary>Minimal capturing <see cref="ILogger{T}"/> so a test can assert on what the driver's own
|
||||
/// logger received (mirrors the equivalent helper in CalculationEvaluatorTests).</summary>
|
||||
private sealed class CapturingLogger : ILogger<CalculationDriver>
|
||||
{
|
||||
public List<(LogLevel Level, string Message)> Entries { get; } = [];
|
||||
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
|
||||
Func<TState, Exception?, string> formatter) =>
|
||||
Entries.Add((logLevel, formatter(state, exception)));
|
||||
}
|
||||
|
||||
private sealed class CapturingPublisher(List<ScriptLogEntry> sink) : IScriptLogPublisher
|
||||
{
|
||||
private readonly object _lock = new();
|
||||
|
||||
+21
@@ -149,6 +149,27 @@ public sealed class GatewayTagProvisionerTests
|
||||
Assert.Equal(1, result.Failed); // non-blocking: no throw
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Driver.Historian.Gateway-002: unlike its siblings (<c>GatewayHistorianDataSource</c>,
|
||||
/// <c>GatewayAlarmHistorianWriter</c>), <see cref="GatewayTagProvisioner"/> had no dispose path at
|
||||
/// all — the gRPC channel <c>GatewayHistorian.CreateProvisioner</c> opens was never closed. The
|
||||
/// provisioner is registered as a process-lifetime DI singleton
|
||||
/// (<c>AddHistorianProvisioning</c>), and the built-in container disposes any singleton instance
|
||||
/// it creates that implements <see cref="IAsyncDisposable"/>/<see cref="IDisposable"/> — regardless
|
||||
/// of the registered service-interface type — so implementing it here is sufficient for a clean
|
||||
/// shutdown with no other wiring change.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task DisposeAsync_disposes_underlying_client()
|
||||
{
|
||||
var fake = new FakeHistorianGatewayClient();
|
||||
var p = Provisioner(fake);
|
||||
|
||||
await ((IAsyncDisposable)p).DisposeAsync();
|
||||
|
||||
Assert.Equal(1, fake.DisposeCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ensured_count_reflects_successful_results()
|
||||
{
|
||||
|
||||
+30
-1
@@ -23,12 +23,41 @@ public sealed class AlarmEventMapperTests
|
||||
Assert.Equal("A1", e.Properties["AlarmId"]);
|
||||
Assert.Equal("HiHi", e.Properties["AlarmName"]);
|
||||
Assert.Equal("Activated", e.Properties["EventKind"]);
|
||||
Assert.Equal("High", e.Properties["Severity"]);
|
||||
// Severity is the AlarmHistorianEvent.Severity XML doc contract: "Mapped to Historian's
|
||||
// numeric priority on the sink side" — NOT the .NET enum name. EventMapper.ParseSeverity on
|
||||
// the read side int.TryParse()s this same property, so a non-numeric string (the old bug:
|
||||
// alarm.Severity.ToString() == "High") silently always read back as the minimum severity (1).
|
||||
Assert.Equal("700", e.Properties["Severity"]);
|
||||
Assert.Equal("operator1", e.Properties["User"]);
|
||||
Assert.Equal("ack note", e.Properties["Comment"]);
|
||||
Assert.Equal("Temp high", e.Properties["Message"]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Driver.Historian.Gateway-001 regression: the write side (<see cref="AlarmEventMapper"/>) and
|
||||
/// the read side (<see cref="EventMapper"/>) must agree on the wire representation of severity —
|
||||
/// a numeric OPC UA Part 9 priority string, matching the convention already used elsewhere in the
|
||||
/// codebase for the same enum (<c>NativeAlarmProjector.MapSeverity</c>: Low=200/Medium=500/
|
||||
/// High=700/Critical=900). Round-tripping through both mappers must reproduce the numeric severity,
|
||||
/// not collapse to the minimum every time.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(AlarmSeverity.Low, (ushort)200)]
|
||||
[InlineData(AlarmSeverity.Medium, (ushort)500)]
|
||||
[InlineData(AlarmSeverity.High, (ushort)700)]
|
||||
[InlineData(AlarmSeverity.Critical, (ushort)900)]
|
||||
public void Severity_round_trips_through_EventMapper_as_numeric_priority(AlarmSeverity severity, ushort expected)
|
||||
{
|
||||
var a = new AlarmHistorianEvent("A1", "Area/Line/Pump1", "HiHi", "LimitAlarm",
|
||||
severity, "Activated", "m", "operator1", null,
|
||||
new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc));
|
||||
|
||||
var wire = AlarmEventMapper.ToHistorianEvent(a);
|
||||
var readBack = EventMapper.ToHistoricalEvent(wire);
|
||||
|
||||
Assert.Equal(expected, readBack.Severity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Null_comment_is_omitted_not_null()
|
||||
{
|
||||
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.HistorianGateway.Contracts.Grpc;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Recorder;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests.Recorder;
|
||||
|
||||
/// <summary>
|
||||
/// Driver.Historian.Gateway-003 (offline unit coverage for the continuous-historization write path):
|
||||
/// before this file, <see cref="GatewayHistorianValueWriter"/> — the recorder's <c>WriteLiveValues</c>
|
||||
/// adapter, in production the write path CLAUDE.md's #491/#537/#538 live-gate history bit hard — was
|
||||
/// exercised only by the env-gated <c>Category=LiveIntegration</c> suite, which skips cleanly offline.
|
||||
/// These tests use the same <see cref="FakeHistorianGatewayClient"/> the rest of the module's offline
|
||||
/// tests share.
|
||||
/// </summary>
|
||||
public sealed class GatewayHistorianValueWriterTests
|
||||
{
|
||||
private static GatewayHistorianValueWriter Writer(FakeHistorianGatewayClient fake) =>
|
||||
new(fake, NullLogger<GatewayHistorianValueWriter>.Instance);
|
||||
|
||||
[Fact]
|
||||
public async Task Success_ack_returns_true()
|
||||
{
|
||||
var fake = new FakeHistorianGatewayClient { WriteLiveValuesResult = new WriteAck { Success = true } };
|
||||
var values = new[] { new HistorizationValue(DateTime.UtcNow, 42.0, 192) };
|
||||
|
||||
var ok = await Writer(fake).WriteLiveValuesAsync("T", values, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Equal(1, fake.WriteLiveValuesCallCount);
|
||||
Assert.Equal("T", fake.LastWriteLiveTag);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Queued_ack_is_treated_as_success()
|
||||
{
|
||||
// A value the gateway durably store-forward-queued must not be re-drained by the recorder.
|
||||
var fake = new FakeHistorianGatewayClient
|
||||
{
|
||||
WriteLiveValuesResult = new WriteAck { Success = false, Queued = true },
|
||||
};
|
||||
var values = new[] { new HistorizationValue(DateTime.UtcNow, 1.0, 192) };
|
||||
|
||||
var ok = await Writer(fake).WriteLiveValuesAsync("T", values, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(ok);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Soft_failure_ack_returns_false_for_retry()
|
||||
{
|
||||
var fake = new FakeHistorianGatewayClient
|
||||
{
|
||||
WriteLiveValuesResult = new WriteAck { Success = false, Queued = false },
|
||||
};
|
||||
var values = new[] { new HistorizationValue(DateTime.UtcNow, 1.0, 192) };
|
||||
|
||||
var ok = await Writer(fake).WriteLiveValuesAsync("T", values, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(ok);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Gateway_exception_is_swallowed_and_returns_false()
|
||||
{
|
||||
// NEVER throw out of the writer — the recorder's drain loop expects a plain bool.
|
||||
var fake = new FakeHistorianGatewayClient { WriteLiveValuesThrows = new InvalidOperationException("boom") };
|
||||
var values = new[] { new HistorizationValue(DateTime.UtcNow, 1.0, 192) };
|
||||
|
||||
var ok = await Writer(fake).WriteLiveValuesAsync("T", values, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(ok);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Empty_batch_is_trivially_acked_without_calling_the_gateway()
|
||||
{
|
||||
var fake = new FakeHistorianGatewayClient();
|
||||
|
||||
var ok = await Writer(fake).WriteLiveValuesAsync(
|
||||
"T", Array.Empty<HistorizationValue>(), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Equal(0, fake.WriteLiveValuesCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Present_timestamp_is_sent_and_absent_timestamp_leaves_the_proto_field_unset()
|
||||
{
|
||||
var fake = new FakeHistorianGatewayClient();
|
||||
var stamped = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
var values = new[]
|
||||
{
|
||||
new HistorizationValue(stamped, 1.0, 192),
|
||||
new HistorizationValue(null, 2.0, 192),
|
||||
};
|
||||
|
||||
await Writer(fake).WriteLiveValuesAsync("T", values, TestContext.Current.CancellationToken);
|
||||
|
||||
var sent = fake.LastWriteLiveValues!;
|
||||
Assert.Equal(2, sent.Count);
|
||||
Assert.Equal(stamped, sent[0].Timestamp!.ToDateTime());
|
||||
Assert.Null(sent[1].Timestamp); // absent -> unset proto field -> gateway server-stamps now
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Quality_and_numeric_value_are_forwarded_unchanged()
|
||||
{
|
||||
var fake = new FakeHistorianGatewayClient();
|
||||
var values = new[] { new HistorizationValue(null, 123.5, 192) };
|
||||
|
||||
await Writer(fake).WriteLiveValuesAsync("T", values, TestContext.Current.CancellationToken);
|
||||
|
||||
var sent = fake.LastWriteLiveValues!;
|
||||
Assert.Equal(123.5, sent[0].NumericValue);
|
||||
Assert.Equal(192u, sent[0].Quality);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Null_tag_throws()
|
||||
{
|
||||
var fake = new FakeHistorianGatewayClient();
|
||||
var values = new[] { new HistorizationValue(null, 1.0, 192) };
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||
Writer(fake).WriteLiveValuesAsync(null!, values, TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Null_values_throws()
|
||||
{
|
||||
var fake = new FakeHistorianGatewayClient();
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||
Writer(fake).WriteLiveValuesAsync("T", null!, TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisposeAsync_disposes_underlying_client()
|
||||
{
|
||||
var fake = new FakeHistorianGatewayClient();
|
||||
var writer = Writer(fake);
|
||||
|
||||
await writer.DisposeAsync();
|
||||
|
||||
Assert.Equal(1, fake.DisposeCallCount);
|
||||
}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Recorder;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests.Recorder;
|
||||
|
||||
/// <summary>
|
||||
/// Driver.Historian.Gateway-003 (offline unit coverage): <see cref="FasterLogHistorizationOutboxTests"/>
|
||||
/// exercises <see cref="HistorizationOutboxEntrySerializer"/> only indirectly, and only with short
|
||||
/// ASCII tag names ("A"/"B"/"C"). The hand-rolled little-endian layout computes its tag byte-length
|
||||
/// from <c>Encoding.UTF8.GetByteCount</c> before writing, so a multi-byte tag exercises the one part
|
||||
/// of the layout arithmetic the indirect coverage never reaches. This asserts the (de)serializer
|
||||
/// directly, in isolation from FasterLog.
|
||||
/// </summary>
|
||||
public sealed class HistorizationOutboxEntrySerializerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Round_trips_all_fields()
|
||||
{
|
||||
var entry = new HistorizationOutboxEntry(
|
||||
Guid.NewGuid(), "Pump1.Temp", 123.456, 192,
|
||||
new DateTime(2026, 1, 1, 12, 34, 56, DateTimeKind.Utc));
|
||||
|
||||
var bytes = HistorizationOutboxEntrySerializer.Serialize(entry);
|
||||
var round = HistorizationOutboxEntrySerializer.Deserialize(bytes);
|
||||
|
||||
Assert.Equal(entry.Id, round.Id);
|
||||
Assert.Equal(entry.Tag, round.Tag);
|
||||
Assert.Equal(entry.NumericValue, round.NumericValue);
|
||||
Assert.Equal(entry.Quality, round.Quality);
|
||||
Assert.Equal(entry.TimestampUtc, round.TimestampUtc);
|
||||
Assert.Equal(entry.TimestampUtc.Kind, round.TimestampUtc.Kind); // ToBinary/FromBinary round-trips Kind
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Round_trips_a_multi_byte_utf8_tag()
|
||||
{
|
||||
// The tag length prefix is a UTF-8 BYTE count, not a char count — a tag whose char count and
|
||||
// byte count diverge (e.g. Ω is 2 bytes, 日 is 3) is the one case that would desync the layout
|
||||
// if GetByteCount/GetBytes ever disagreed with what Deserialize slices out.
|
||||
var entry = new HistorizationOutboxEntry(
|
||||
Guid.NewGuid(), "Pumpe_Ω_日本語_Temp", -1.5, 0, DateTime.UtcNow);
|
||||
|
||||
var round = HistorizationOutboxEntrySerializer.Deserialize(
|
||||
HistorizationOutboxEntrySerializer.Serialize(entry));
|
||||
|
||||
Assert.Equal(entry.Tag, round.Tag);
|
||||
Assert.Equal(entry.NumericValue, round.NumericValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Round_trips_an_empty_tag()
|
||||
{
|
||||
var entry = new HistorizationOutboxEntry(Guid.NewGuid(), string.Empty, 0.0, 0, DateTime.UtcNow);
|
||||
|
||||
var round = HistorizationOutboxEntrySerializer.Deserialize(
|
||||
HistorizationOutboxEntrySerializer.Serialize(entry));
|
||||
|
||||
Assert.Equal(string.Empty, round.Tag);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Round_trips_boundary_quality_and_negative_value()
|
||||
{
|
||||
var entry = new HistorizationOutboxEntry(Guid.NewGuid(), "T", double.MinValue, ushort.MaxValue, DateTime.UtcNow);
|
||||
|
||||
var round = HistorizationOutboxEntrySerializer.Deserialize(
|
||||
HistorizationOutboxEntrySerializer.Serialize(entry));
|
||||
|
||||
Assert.Equal(ushort.MaxValue, round.Quality);
|
||||
Assert.Equal(double.MinValue, round.NumericValue);
|
||||
}
|
||||
}
|
||||
@@ -301,6 +301,65 @@ public sealed class MTConnectDriverProbeTests
|
||||
r.Latency.ShouldBeNull();
|
||||
}
|
||||
|
||||
// ── review follow-up: the probe must request the URL the DRIVER will request ──
|
||||
|
||||
/// <summary>
|
||||
/// <b>Test Connect has to probe the driver's own request path, device scope included.</b>
|
||||
/// <c>MTConnectAgentClient.BuildRequestUri</c> narrows every read to
|
||||
/// <c>{AgentUri}/{DeviceName}/probe</c>, so a probe that requested the agent-wide
|
||||
/// <c>{AgentUri}/probe</c> answers a different question from the one the operator asked. A
|
||||
/// mistyped or since-renamed <c>deviceName</c> then shows GREEN on Test Connect and Faults the
|
||||
/// driver at <c>InitializeAsync</c> — the Agent answers that path with an <c>MTConnectError</c>
|
||||
/// under HTTP 200, which the parser surfaces as <see cref="InvalidDataException"/>.
|
||||
/// <para>
|
||||
/// This is a different class from the accepted "reachable ≠ deployable" gap the type
|
||||
/// documents (a bad tag entry, a non-positive timeout): those do not change the URL, and
|
||||
/// the whole point of a reachability probe is which endpoint it reaches.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Configured_deviceName_scopes_the_probe_request_the_way_the_driver_does()
|
||||
{
|
||||
Uri? requested = null;
|
||||
var probe = ProbeWithHandler((request, _) =>
|
||||
{
|
||||
requested = request.RequestUri;
|
||||
|
||||
return Task.FromResult(XmlResponse(ValidProbeXml));
|
||||
});
|
||||
|
||||
var r = await probe.ProbeAsync(
|
||||
"{\"agentUri\":\"http://stub-agent/\",\"deviceName\":\"VMC-1\"}", QuickTimeout, CancellationToken.None);
|
||||
|
||||
r.Ok.ShouldBeTrue();
|
||||
requested.ShouldNotBeNull();
|
||||
requested.AbsolutePath.ShouldBe("/VMC-1/probe");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// …and with no <c>deviceName</c> the probe stays agent-wide, exactly as the driver does. The
|
||||
/// falsification control for the test above: unconditionally inserting a segment would pass it
|
||||
/// while breaking every unscoped config.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task An_absent_deviceName_leaves_the_probe_agent_wide()
|
||||
{
|
||||
Uri? requested = null;
|
||||
var probe = ProbeWithHandler((request, _) =>
|
||||
{
|
||||
requested = request.RequestUri;
|
||||
|
||||
return Task.FromResult(XmlResponse(ValidProbeXml));
|
||||
});
|
||||
|
||||
var r = await probe.ProbeAsync(
|
||||
"{\"agentUri\":\"http://stub-agent/\"}", QuickTimeout, CancellationToken.None);
|
||||
|
||||
r.Ok.ShouldBeTrue();
|
||||
requested.ShouldNotBeNull();
|
||||
requested.AbsolutePath.ShouldBe("/probe");
|
||||
}
|
||||
|
||||
// ── minor: credentialed agentUri + a SUCCESSFUL response must still redact ──
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -990,6 +990,78 @@ public sealed class MTConnectSubscribeTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>A re-baseline that never resolves anything must not reopen without a backoff.</b>
|
||||
/// <c>StreamVerdict.Reopen</c> is "a protocol event, not a fault", so it reopens immediately
|
||||
/// and resets the ladder — correct for the two cases it was written for (a sequence gap and an
|
||||
/// <c>OUT_OF_RANGE</c> rejection), both of which the <c>/current</c> re-baseline genuinely
|
||||
/// fixes, so they cannot recur.
|
||||
/// <para>
|
||||
/// But the <see cref="InvalidDataException"/> catch that routes <c>OUT_OF_RANGE</c> to the
|
||||
/// re-baseline also catches an <b>unparseable chunk</b> and an oversized part — conditions a
|
||||
/// <c>/current</c> cannot fix. Reopening those with the ladder reset is an unbounded hot
|
||||
/// loop against the Agent (one <c>/sample</c> AND one <c>/current</c> per iteration, as fast
|
||||
/// as they complete, one Warning each), which is exactly what the reconnect backoff exists
|
||||
/// to prevent everywhere else in this pump.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The discriminator is the one the transient path already uses: did THIS connection deliver
|
||||
/// a chunk? A re-baseline behind a working stream still reopens instantly; one on a
|
||||
/// connection that has never delivered advances the ladder. Asserted as monotonic growth,
|
||||
/// never as a duration.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_rebaseline_that_delivers_nothing_still_advances_the_reconnect_backoff()
|
||||
{
|
||||
// A 1 ms cap keeps the failing loop from actually sleeping; nothing here asserts a duration.
|
||||
var options = Opts(new MTConnectReconnectOptions { MinBackoffMs = 0, MaxBackoffMs = 1 });
|
||||
var (driver, client) = await InitializedDriverAsync(options: options);
|
||||
|
||||
// Sticky, and NOT a transient stream fault: a chunk this driver can never parse. /current keeps
|
||||
// answering, so every re-baseline "succeeds" and the pump reopens — forever.
|
||||
client.SampleFailure = new InvalidDataException("chunk is not a well-formed MTConnectStreams document");
|
||||
|
||||
var handle = await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct);
|
||||
|
||||
try
|
||||
{
|
||||
await client.WaitForSampleCallsAsync(3).WaitAsync(Watchdog, Ct);
|
||||
var early = driver.ReconnectAttempts;
|
||||
|
||||
await client.WaitForSampleCallsAsync(6).WaitAsync(Watchdog, Ct);
|
||||
|
||||
early.ShouldBeGreaterThanOrEqualTo(2);
|
||||
driver.ReconnectAttempts.ShouldBeGreaterThan(early);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await driver.UnsubscribeAsync(handle, Ct).WaitAsync(Watchdog, Ct);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// …and the falsification control for the test above: a re-baseline BEHIND a stream that was
|
||||
/// delivering keeps its immediate reopen. Without this, "advance the ladder on every re-baseline"
|
||||
/// would pass the test above while making a healthy Agent's ordinary buffer roll cost a backoff.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_rebaseline_after_a_delivered_chunk_still_reopens_immediately()
|
||||
{
|
||||
var (driver, client) = await InitializedDriverAsync();
|
||||
await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct);
|
||||
|
||||
// One good chunk, so this connection has provably delivered…
|
||||
await client.PumpAsync(Chunk(PrimedNextSequence, 113, ("dev1_pos", "1.5"))).WaitAsync(Watchdog, Ct);
|
||||
|
||||
// …then the Agent's buffer rolls past the cursor. The re-baseline is a protocol event behind a
|
||||
// working stream: no backoff, no ladder.
|
||||
client.CurrentAnswers.Enqueue(Chunk(5000, 5005, ("dev1_pos", "2.5")));
|
||||
await client.PumpAsync(Chunk(5000, 5005, ("dev1_pos", "2.5"))).WaitAsync(Watchdog, Ct);
|
||||
|
||||
driver.ReconnectAttempts.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>I3 — a non-positive cap is an operator-authorable hot loop.</b> Clamping to it (the
|
||||
/// obvious <c>Math.Max(0, …)</c>) makes EVERY delay zero, so the pump reconnect-spins as fast
|
||||
|
||||
@@ -854,6 +854,27 @@ public sealed class MqttBrowseSessionTests
|
||||
s.PublishCountForTest.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Plant1/#")]
|
||||
[InlineData("Plant1/+")]
|
||||
[InlineData("+/EdgeA")]
|
||||
[InlineData("#/EdgeA")]
|
||||
public async Task RequestRebirthAsync_WildcardScope_PublishesNothing(string scope)
|
||||
{
|
||||
// SparkplugRebirthScope (the AdminUI's typed input) rejects a wildcard and its own remarks say
|
||||
// it is "advisory, not the gate" because ResolveRebirthTargets "refuses an unusable one BEFORE
|
||||
// any message leaves". That claim has to be true here: an unobserved `{group}/{edgeNode}` scope
|
||||
// is honoured on purpose (a node that has not birthed is the prime rebirth target), and the
|
||||
// label check behind it looked only at length and control characters. A wildcard segment would
|
||||
// therefore have published `spBv1.0/Plant1/NCMD/#` — a PUBLISH the MQTT spec forbids, which a
|
||||
// conformant broker answers by closing the browse connection.
|
||||
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
|
||||
|
||||
await Should.ThrowAsync<ArgumentException>(
|
||||
async () => await s.RequestRebirthAsync(scope, TestContext.Current.CancellationToken));
|
||||
s.PublishCountForTest.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestRebirthAsync_InPlainMode_IsRefused()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The driver's own session lifecycle, against the <see cref="MiniBroker"/> fake: what
|
||||
/// <see cref="MqttDriver.InitializeAsync"/> does to the session it is <i>replacing</i>, and what a
|
||||
/// mis-authored duration knob does to the two paths whose whole job is to not fail
|
||||
/// (<see cref="MqttDriver.ShutdownAsync"/> / <see cref="MqttDriver.DisposeAsync"/>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Plain mode deliberately: it is the only mode whose initialize needs no SUBSCRIBE, and
|
||||
/// <see cref="MiniBroker"/> answers CONNECT and PINGREQ and nothing else — so a Sparkplug driver
|
||||
/// would park on its group SUBSCRIBE until the deadline and prove nothing about connections.
|
||||
/// </remarks>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class MqttDriverSessionLifecycleTests
|
||||
{
|
||||
/// <summary>
|
||||
/// <b>Driver.Mqtt-002.</b> <c>InitializeCoreAsync</c> publishes <c>_connection</c> — an
|
||||
/// <see cref="IAsyncDisposable"/> — so re-initializing a live driver without tearing down first
|
||||
/// stranded a connected, supervised broker session that no later dispose could reach, and the
|
||||
/// abandoned session's reconnect supervisor kept it alive against the broker indefinitely.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Reachable with no teardown of the driver's own: <c>DriverInstanceActor</c> re-runs
|
||||
/// <c>InitializeAsync</c> with no <c>ShutdownAsync</c> when an <c>ApplyDelta</c> arrives while
|
||||
/// the driver is Connecting/Reconnecting (<c>AdoptConfigDuringInit</c>). The
|
||||
/// <c>teardownFirst</c> flag on the #527 retry path covers only the retry path.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The assertion is the broker's <b>live socket count</b>, not the driver's own view: a leak
|
||||
/// is precisely the case where the driver believes it holds one session and the broker is
|
||||
/// serving two. <see cref="MiniBroker.AcceptedCount"/> is asserted alongside it as the
|
||||
/// control — without it, a second initialize that never dialled at all would satisfy
|
||||
/// "exactly one live connection" just as well.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task InitializeAsync_OnALiveDriver_TearsDownThePreviousSession()
|
||||
{
|
||||
using var broker = new MiniBroker();
|
||||
var options = BrokerOptions(broker.Port);
|
||||
await using var driver = new MqttDriver(options, "d", null);
|
||||
|
||||
await driver.InitializeAsync(Json(options), TestContext.Current.CancellationToken);
|
||||
await WaitForAsync(() => broker.LiveConnections == 1, "the first session to be established");
|
||||
|
||||
await driver.InitializeAsync(Json(options), TestContext.Current.CancellationToken);
|
||||
|
||||
broker.AcceptedCount.ShouldBe(2, "the second initialize must genuinely open a new session");
|
||||
await WaitForAsync(
|
||||
() => broker.LiveConnections == 1,
|
||||
"the replaced session to be torn down rather than left live and unreachable");
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>Driver.Mqtt-003.</b> <c>connectTimeoutSeconds</c> carries a <c>[Range]</c> annotation that
|
||||
/// nothing validates on the runtime path — the blob is deserialized straight out of the deploy
|
||||
/// artifact. A negative value is outside the legal range of both
|
||||
/// <see cref="SemaphoreSlim.WaitAsync(TimeSpan)"/> and <see cref="CancellationTokenSource"/>, so
|
||||
/// it threw <see cref="ArgumentOutOfRangeException"/> out of shutdown and dispose — the two calls
|
||||
/// that must always complete, and the ones a host makes while tearing a bad deployment down.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ShutdownAndDispose_WithANegativeConnectTimeout_DoNotThrow()
|
||||
{
|
||||
var options = BrokerOptions(1) with { ConnectTimeoutSeconds = -1, KeepAliveSeconds = -1 };
|
||||
var driver = new MqttDriver(options, "d", null);
|
||||
|
||||
await Should.NotThrowAsync(async () =>
|
||||
await driver.ShutdownAsync(TestContext.Current.CancellationToken));
|
||||
await Should.NotThrowAsync(async () => await driver.DisposeAsync());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The control for the test above: the normalization must not quietly rewrite a <i>legal</i>
|
||||
/// value, or a deployment's authored connect budget would silently become the default.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task InitializeAsync_WithAnAuthoredConnectTimeout_KeepsIt()
|
||||
{
|
||||
using var broker = new MiniBroker { Blackhole = true };
|
||||
var options = BrokerOptions(broker.Port) with { ConnectTimeoutSeconds = 2 };
|
||||
await using var driver = new MqttDriver(options, "d", null);
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
await Should.ThrowAsync<TimeoutException>(async () =>
|
||||
await driver.InitializeAsync(Json(options), TestContext.Current.CancellationToken));
|
||||
sw.Stop();
|
||||
|
||||
sw.Elapsed.ShouldBeLessThan(
|
||||
TimeSpan.FromSeconds(10),
|
||||
"the authored 2s budget must still bound the connect, not the 15s default");
|
||||
}
|
||||
|
||||
private static async Task WaitForAsync(Func<bool> condition, string what)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (condition())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await Task.Delay(25, TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
throw new Xunit.Sdk.XunitException($"Timed out waiting for {what}.");
|
||||
}
|
||||
|
||||
private static MqttDriverOptions BrokerOptions(int port) => new()
|
||||
{
|
||||
Mode = MqttMode.Plain,
|
||||
Host = "127.0.0.1",
|
||||
Port = port,
|
||||
UseTls = false,
|
||||
ProtocolVersion = MqttProtocolVersion.V311,
|
||||
KeepAliveSeconds = 300,
|
||||
ConnectTimeoutSeconds = 5,
|
||||
ReconnectMinBackoffSeconds = 1,
|
||||
ReconnectMaxBackoffSeconds = 2,
|
||||
};
|
||||
|
||||
private static string Json(MqttDriverOptions options) => JsonSerializer.Serialize(options, MqttJson.Options);
|
||||
}
|
||||
@@ -252,6 +252,54 @@ public sealed class MqttInspectAcceptGateTests
|
||||
warnings.ShouldNotContain(w => w.Contains("Plain mode"), "an unserved tag is not a mode-dependent case");
|
||||
}
|
||||
|
||||
// ---- The two authoring-only rules the deploy gate used to pass over in silence ----
|
||||
|
||||
/// <summary>
|
||||
/// <c>MqttTagConfigModel.ValidatePlain</c> refuses a wildcard <c>writeTopic</c> outright, but the
|
||||
/// typed editor is only ONE authoring path — the raw-JSON textarea, CSV import and the API all
|
||||
/// reach the deploy gate directly. A wildcard cannot be published to at all (MQTT-3.3.2-2), so the
|
||||
/// tag reads perfectly while every write is refused; that has to be said somewhere the operator
|
||||
/// sees it, and an ADVISORY is the right class because the blob IS accepted.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_wildcard_write_topic_is_advised_on_an_accepted_blob()
|
||||
{
|
||||
const string Blob = """{"topic":"plant/line1/temp","writeTopic":"plant/+/set"}""";
|
||||
|
||||
EitherModeAccepts(Blob).ShouldBeTrue("the tag still READS fine — only the write is unaddressable");
|
||||
MqttTagDefinitionFactory.Inspect(Blob)
|
||||
.ShouldContain(w => w.Contains("writeTopic") && w.Contains("wildcard"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A Sparkplug group / edge-node / device id is a single MQTT topic segment, so a decoded incoming
|
||||
/// id can never contain <c>/</c>, <c>+</c> or <c>#</c> — an authored one that does is a binding
|
||||
/// that can never resolve, and the mapper accepts it (it validates presence, not shape). The
|
||||
/// AdminUI model calls this out in words and refuses the save; the deploy gate said nothing.
|
||||
/// </summary>
|
||||
/// <param name="key">The key the advisory must name.</param>
|
||||
/// <param name="blob">The tag config carrying an unbindable id.</param>
|
||||
[Theory]
|
||||
[InlineData("groupId", """{"groupId":"Ot/Sim","edgeNodeId":"EdgeA","metricName":"T"}""")]
|
||||
[InlineData("edgeNodeId", """{"groupId":"OtSim","edgeNodeId":"Edge+A","metricName":"T"}""")]
|
||||
[InlineData("deviceId", """{"groupId":"OtSim","edgeNodeId":"EdgeA","deviceId":"F#1","metricName":"T"}""")]
|
||||
public void A_sparkplug_id_that_is_not_a_topic_segment_is_advised(string key, string blob)
|
||||
{
|
||||
EitherModeAccepts(blob).ShouldBeTrue("the reader accepts it — which is exactly why it must be advised");
|
||||
MqttTagDefinitionFactory.Inspect(blob).ShouldContain(w => w.Contains(key));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The falsification control for the rule above: <c>metricName</c> is NOT a topic segment — it
|
||||
/// rides in the payload — and Sparkplug's own canonical names use <c>/</c>. Advising it would
|
||||
/// report every conformant <c>Node Control/Rebirth</c> tag as broken.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_metric_name_containing_a_slash_is_never_advised()
|
||||
=> MqttTagDefinitionFactory
|
||||
.Inspect("""{"groupId":"OtSim","edgeNodeId":"EdgeA","metricName":"Node Control/Rebirth"}""")
|
||||
.ShouldBeEmpty();
|
||||
|
||||
/// <summary>
|
||||
/// Both gates must keep agreeing on the VERDICT (Gitea #557 item 2). The #554 authoring consult in
|
||||
/// <c>MqttTagConfigModel</c> asks the identical union and only words a REFUSAL, so an advisory can
|
||||
|
||||
@@ -827,6 +827,66 @@ public sealed class MqttSubscriptionManagerTests
|
||||
transport.Calls.Count.ShouldBe(1); // the initial subscribe only — no zombie re-subscribe
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// Session rebind — this manager OUTLIVES the broker session it established filters on
|
||||
// ---------------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// <b>Driver.Mqtt-001.</b> A driver-host reconnect (ShutdownAsync + InitializeAsync, the #527
|
||||
/// watchdog's path) builds a brand-new <see cref="MqttConnection"/> but keeps <i>this</i> manager
|
||||
/// — <c>MqttDriver.AdoptOptions</c> only replaces it when the ingest identity changed. The new
|
||||
/// session holds none of the old one's subscriptions, so the host's post-reconnect re-subscribe
|
||||
/// must actually reach the broker. While <c>_subscribedTopics</c> survived the rebind it resolved
|
||||
/// an empty pending set, sent no SUBSCRIBE at all, and left the driver Connected/Healthy and
|
||||
/// permanently deaf.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The rebound connection is deliberately a real, <b>unconnected</b> one: its
|
||||
/// <c>SubscribeAsync</c> refuses ("no established session"), which <c>EstablishAsync</c> catches
|
||||
/// and turns into <c>BadCommunicationError</c> on the affected references. That Bad IS the proof
|
||||
/// the subscribe was attempted — with the bug the tag keeps the Good value it was last delivered,
|
||||
/// because nothing is attempted at all.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AttachTo_ForgetsTheFiltersEstablishedOnThePreviousSession()
|
||||
{
|
||||
var transport = new FakeTransport();
|
||||
var mgr = NewManager(transport);
|
||||
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]);
|
||||
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
mgr.HandleMessage(OvenTopic, "21.5"u8.ToArray(), retained: false);
|
||||
mgr.Values.Read(OvenTempPath).StatusCode.ShouldBe(Good);
|
||||
|
||||
// The session underneath is rebuilt and this manager is re-attached to it.
|
||||
await using var rebound = new MqttConnection(
|
||||
new MqttDriverOptions { Host = "127.0.0.1", Port = 1, UseTls = false }, "mqtt-1");
|
||||
mgr.AttachTo(rebound);
|
||||
|
||||
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
|
||||
mgr.Values.Read(OvenTempPath).StatusCode.ShouldBe(
|
||||
BadCommunicationError,
|
||||
"the re-subscribe must be issued against the NEW session, not skipped as already-established");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The falsifiability control for the test above: within ONE session the dedupe must still hold,
|
||||
/// or the fix would simply be "re-SUBSCRIBE everything on every subscribe" — which re-triggers
|
||||
/// every topic's retained seed and is the behaviour <c>_subscribedTopics</c> exists to prevent.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_TwiceOnTheSameSession_StillIssuesOneSubscribe()
|
||||
{
|
||||
var transport = new FakeTransport();
|
||||
var mgr = NewManager(transport);
|
||||
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]);
|
||||
|
||||
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
|
||||
transport.Calls.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>Records every subscribe call so dedupe / re-subscribe can be asserted without a broker.</summary>
|
||||
private sealed class FakeTransport : IMqttSubscribeTransport
|
||||
{
|
||||
|
||||
@@ -237,6 +237,58 @@ public sealed class MqttWriteEncoderTests
|
||||
EncodeTopic(MqttMode.SparkplugB, both).ShouldBe("spBv1.0/OtOpcUaSim/DCMD/EdgeA/Filler1");
|
||||
}
|
||||
|
||||
// ---- A destination that cannot be published to is not a destination -------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData("factory/+/set")]
|
||||
[InlineData("factory/oven/#")]
|
||||
public void A_wildcard_write_topic_is_refused_rather_than_published(string writeTopic)
|
||||
{
|
||||
// MQTT-3.3.2-2: a PUBLISH topic MUST NOT contain a wildcard, and a conformant broker answers a
|
||||
// protocol violation by CLOSING the connection — which, for this driver, is the same connection
|
||||
// every subscription lives on. So one write to a mis-authored tag takes the WHOLE driver dark,
|
||||
// not just that tag. The typed AdminUI editor already refuses this, but a raw-JSON / CSV /
|
||||
// API-authored blob reaches the encoder untouched, so the refusal has to live here too.
|
||||
MqttWriteEncoder.TryEncode(MqttMode.Plain, Plain(writeTopic), 1, out var encoded)
|
||||
.ShouldBe(MqttWriteEncodeError.NoWriteDestination);
|
||||
encoded.Topic.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Ot/Sim", "EdgeA", null)]
|
||||
[InlineData("OtSim", "Edge+A", null)]
|
||||
[InlineData("OtSim", "EdgeA", "Fill#er")]
|
||||
[InlineData("OtSim", "EdgeA", "Fill/er")]
|
||||
public void A_binding_tuple_that_is_not_a_legal_topic_segment_has_no_command_address(
|
||||
string groupId, string edgeNodeId, string? deviceId)
|
||||
{
|
||||
// The same protocol hazard on the Sparkplug side, plus one the Plain side does not have: the
|
||||
// tuple IS the command topic, so a '/' silently re-shapes it into a DIFFERENT address (an
|
||||
// NCMD for group "Ot" node "Sim" rather than the tag's own node) while '+'/'#' make it
|
||||
// unpublishable. Refusing is the only honest verdict for all three.
|
||||
var def = Sparkplug(deviceId) with { GroupId = groupId, EdgeNodeId = edgeNodeId };
|
||||
|
||||
MqttWriteEncoder.TryEncode(MqttMode.SparkplugB, def, 1.0, out _)
|
||||
.ShouldBe(MqttWriteEncodeError.NoWriteDestination);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_metric_name_containing_a_slash_still_encodes()
|
||||
{
|
||||
// The falsification control for the rule above. Sparkplug's own canonical metric names use '/'
|
||||
// (`Node Control/Rebirth`), and metricName rides in the PAYLOAD, never in the topic — so the
|
||||
// segment rule must not reach it or every conformant node-control write would be refused.
|
||||
MqttWriteEncoder.TryEncode(
|
||||
MqttMode.SparkplugB,
|
||||
Sparkplug(deviceId: null, DriverDataType.Boolean, metricName: "Node Control/Rebirth"),
|
||||
true,
|
||||
out var encoded)
|
||||
.ShouldBe(MqttWriteEncodeError.None);
|
||||
|
||||
encoded.Topic.ShouldBe("spBv1.0/OtOpcUaSim/NCMD/EdgeA");
|
||||
Payload.Parser.ParseFrom(encoded.Payload).Metrics[0].Name.ShouldBe("Node Control/Rebirth");
|
||||
}
|
||||
|
||||
private static string EncodeTopic(MqttMode mode, MqttTagDefinition def)
|
||||
{
|
||||
MqttWriteEncoder.TryEncode(mode, def, 1.0, out var encoded).ShouldBe(MqttWriteEncodeError.None);
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
using System.Data.Common;
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The catalog loader's <b>default-schema fallback</b>: a login with no default schema mapped (the
|
||||
/// dialect's <c>DefaultSchemaSql</c> answers NULL/blank — a real Postgres shape when the connection's
|
||||
/// <c>search_path</c> resolves to nothing, and a real SQL Server shape for a login with no
|
||||
/// <c>DEFAULT_SCHEMA</c>) must still resolve an <em>unqualified</em> authored table when the connection
|
||||
/// can see exactly one schema.
|
||||
/// <para>Without the fallback the loader resolves the default schema to the empty string, the tables
|
||||
/// query is issued for schema <c>''</c>, it matches nothing, and <b>every unqualified tag on the driver
|
||||
/// is rejected by the gate</b> and reads <c>BadNodeIdUnknown</c> — a whole-driver outage with the
|
||||
/// database perfectly healthy and every tag correctly authored.</para>
|
||||
/// </summary>
|
||||
public sealed class SqlCatalogDefaultSchemaTests
|
||||
{
|
||||
private const string DriverInstanceId = "sql-default-schema";
|
||||
private const string ConfigJson = """{"provider":"SqlServer"}""";
|
||||
|
||||
/// <summary>
|
||||
/// One visible schema + a blank default ⇒ the unqualified name resolves in that schema and polls.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task An_unqualified_table_resolves_when_the_server_reports_no_default_schema()
|
||||
{
|
||||
using var fixture = new SqlitePollFixture();
|
||||
await using var driver = NewDriver(fixture, new BlankDefaultSchemaDialect());
|
||||
|
||||
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
|
||||
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
var snapshot = (await driver.ReadAsync(["Speed"], CancellationToken.None)).ShouldHaveSingleItem();
|
||||
SqlStatusCodes.IsGood(snapshot.StatusCode).ShouldBeTrue(
|
||||
"a login with no default schema must still resolve an unqualified name in the sole visible schema");
|
||||
snapshot.Value.ShouldBe(SqlitePollFixture.PresentValue);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The falsifiability control: with <b>more than one</b> visible schema there is no defensible guess,
|
||||
/// so an unqualified name must NOT be resolved against an arbitrary one — it is rejected, and the gate
|
||||
/// says so by name. A fallback that picked the first schema would pass the test above and silently
|
||||
/// bind tags to whichever schema sorted first here.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task An_unqualified_table_is_still_rejected_when_several_schemas_are_visible()
|
||||
{
|
||||
using var fixture = new SqlitePollFixture();
|
||||
await using var driver = NewDriver(fixture, new BlankDefaultSchemaDialect(twoSchemas: true));
|
||||
|
||||
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
|
||||
|
||||
var snapshot = (await driver.ReadAsync(["Speed"], CancellationToken.None)).ShouldHaveSingleItem();
|
||||
snapshot.StatusCode.ShouldBe(SqlStatusCodes.BadNodeIdUnknown);
|
||||
}
|
||||
|
||||
private static SqlDriver NewDriver(SqlitePollFixture fixture, ISqlDialect dialect)
|
||||
=> new(
|
||||
new SqlDriverOptions
|
||||
{
|
||||
RawTags = [KvEntry("Speed", SqlitePollFixture.PresentKey)],
|
||||
OperationTimeout = TimeSpan.FromSeconds(15),
|
||||
CommandTimeout = TimeSpan.FromSeconds(10),
|
||||
},
|
||||
DriverInstanceId,
|
||||
dialect,
|
||||
fixture.ConnectionString,
|
||||
factory: fixture.Factory,
|
||||
logger: NullLogger<SqlDriver>.Instance);
|
||||
|
||||
private static RawTagEntry KvEntry(string rawPath, string keyValue)
|
||||
=> new(rawPath, string.Create(CultureInfo.InvariantCulture, $$"""
|
||||
{
|
||||
"driver": "Sql",
|
||||
"model": "KeyValue",
|
||||
"table": "{{SqlitePollFixture.KeyValueTable}}",
|
||||
"keyColumn": "{{SqlitePollFixture.KeyColumn}}",
|
||||
"keyValue": "{{keyValue}}",
|
||||
"valueColumn": "{{SqlitePollFixture.ValueColumn}}"
|
||||
}
|
||||
"""), WriteIdempotent: false);
|
||||
|
||||
/// <summary>
|
||||
/// Delegates every member to <see cref="SqliteDialect"/> except <see cref="DefaultSchemaSql"/> (which
|
||||
/// answers the empty string, standing in for a login with no default schema) and optionally
|
||||
/// <see cref="ListSchemasSql"/> (which can report a second, empty schema so the ambiguous case is
|
||||
/// reachable).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A decorator rather than a subclass, for the reason <c>SqlCatalogGateDriverTests</c> gives:
|
||||
/// <see cref="SqliteDialect"/> is sealed, and <c>new</c>-hiding a property would leave interface
|
||||
/// dispatch calling the base, so the substitution would silently not happen.
|
||||
/// </remarks>
|
||||
private sealed class BlankDefaultSchemaDialect(bool twoSchemas = false) : ISqlDialect
|
||||
{
|
||||
private static readonly SqliteDialect Inner = new();
|
||||
|
||||
public SqlProvider Provider => Inner.Provider;
|
||||
|
||||
public DbProviderFactory Factory => Inner.Factory;
|
||||
|
||||
public string LivenessSql => Inner.LivenessSql;
|
||||
|
||||
public string SingleRowLimitPrefix => Inner.SingleRowLimitPrefix;
|
||||
|
||||
public string SingleRowLimitSuffix => Inner.SingleRowLimitSuffix;
|
||||
|
||||
public string ListSchemasSql => twoSchemas
|
||||
? Inner.ListSchemasSql + " UNION ALL SELECT 'other' AS TABLE_SCHEMA"
|
||||
: Inner.ListSchemasSql;
|
||||
|
||||
/// <summary>The server has no default schema for this login — the case the fallback covers.</summary>
|
||||
public string DefaultSchemaSql => "SELECT ''";
|
||||
|
||||
public string ListTablesSql => Inner.ListTablesSql;
|
||||
|
||||
public string ListColumnsSql => Inner.ListColumnsSql;
|
||||
|
||||
public string ParameterMarker(string baseName) => Inner.ParameterMarker(baseName);
|
||||
|
||||
public string ParameterBindName(string baseName) => Inner.ParameterBindName(baseName);
|
||||
|
||||
public void ConfigureCommand(DbCommand command) => Inner.ConfigureCommand(command);
|
||||
|
||||
public string QuoteIdentifier(string ident) => Inner.QuoteIdentifier(ident);
|
||||
|
||||
public DriverDataType MapColumnType(string sqlDataType) => Inner.MapColumnType(sqlDataType);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user