docs(archreview): remediation plans + fix flagged doc drift
Add the 7 per-domain design+implementation plans (archreview/plans/) with an index, produced from the 2026-07-08 architecture review. Fix two confirmed doc drifts the review flagged (theme #5): - CLAUDE.md KNOWN LIMITATION 2: the continuous-historization historized-ref feed IS wired (AddressSpaceApplier.FeedHistorizedRefs -> UpdateHistorizedRefs -> recorder); rewrite to reflect that value-capture is code-complete and only the live end-to-end + restart-convergence verification remains. - CLAUDE.md ScriptAnalysis gating: endpoints use Roles=Administrator,Designer via RequireAuthorization, not the FleetAdmin policy.
This commit is contained in:
@@ -0,0 +1,616 @@
|
||||
# Architecture Review 05 — Protocol Drivers
|
||||
|
||||
**Date:** 2026-07-08
|
||||
**Commit:** `9cad9ed0`
|
||||
**Scope:** the seven cross-platform protocol drivers and their satellites —
|
||||
`Driver.Modbus` (+ `.Addressing`, `.Contracts`), `Driver.S7` (+ `.Contracts`),
|
||||
`Driver.AbCip` (+ `.Contracts`), `Driver.AbLegacy` (+ `.Contracts`),
|
||||
`Driver.TwinCAT` (+ `.Contracts`), `Driver.FOCAS` (+ `.Contracts`),
|
||||
`Driver.OpcUaClient` (+ `.Contracts`, `.Browser`), and `src/Drivers/Cli/*`
|
||||
(six per-driver CLI harnesses + `Cli.Common`). Test projects under
|
||||
`tests/Drivers/` and `tests/Drivers/Cli/` were assessed for coverage only.
|
||||
Galaxy and the Historian drivers are out of scope.
|
||||
|
||||
**Method:** Modbus was read exhaustively as the reference driver (it is the
|
||||
documented origin of the shared `PollGroupEngine` and the richest read
|
||||
planner); each other driver's main client/poller/factory/probe/contracts
|
||||
files were then read in full and diffed structurally against the Modbus
|
||||
shape. Cross-cutting greps covered TODO/HACK/NotImplemented, JSON
|
||||
serializer options, `PollGroupEngine` construction, and interface
|
||||
declarations. File references are repo-relative; drivers are abbreviated to
|
||||
their suffix (e.g. `Driver.S7/S7Driver.cs`).
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### The common driver shape
|
||||
|
||||
Every protocol driver is expected to follow the same template:
|
||||
|
||||
1. **Factory extension** (`<X>DriverFactoryExtensions.Register`) registers a
|
||||
`DriverTypeName → CreateInstance(id, configJson)` closure with the
|
||||
`DriverFactoryRegistry` (wired centrally in
|
||||
`src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs`).
|
||||
Config parses through a string-typed DTO with fail-loud `ParseEnum`
|
||||
helpers and `PropertyNameCaseInsensitive` + comment/trailing-comma
|
||||
tolerance.
|
||||
2. **Driver class** implements `IDriver, ITagDiscovery, IReadable,
|
||||
IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver,
|
||||
IDisposable, IAsyncDisposable` (Modbus,
|
||||
`Driver.Modbus/ModbusDriver.cs:21-22`), with optional capability
|
||||
interfaces (`IAlarmSource`, `IRediscoverable`, `IHistoryProvider`) per
|
||||
protocol.
|
||||
3. **Reference resolution** goes through the shared
|
||||
`EquipmentTagRefResolver<TDef>`
|
||||
(`src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/EquipmentTagRefResolver.cs`),
|
||||
bridging authored tag-table names and raw equipment-tag `TagConfig` JSON
|
||||
blobs (parsed once by a per-driver `<X>EquipmentTagParser` in the
|
||||
Contracts project, cached, cleared on teardown).
|
||||
4. **Subscriptions** overlay a polling loop via the shared `PollGroupEngine`
|
||||
(`Core.Abstractions/PollGroupEngine.cs`) for protocols without a push
|
||||
model; the driver supplies the batch reader + on-change bridge.
|
||||
5. **Health** is an immutable `DriverHealth` snapshot published via
|
||||
`Volatile.Read/Write`; failures set `Degraded` while retaining
|
||||
`LastSuccessfulRead`; `Faulted` is reserved for permanent config faults.
|
||||
6. **Writes** return per-item `WriteResult` with protocol-error → OPC UA
|
||||
StatusCode mapping (a per-driver `<X>StatusMapper` or switch). Accurate
|
||||
per-item statuses are load-bearing: the server's write-outcome
|
||||
self-correction (`OtOpcUaNodeManager` reverting the node value on a
|
||||
failed device write) only works if the driver reports the failure.
|
||||
7. **Probe loop** — a background task issuing a cheap protocol-level
|
||||
handshake, raising `OnHostStatusChanged` on Running↔Stopped transitions
|
||||
under a probe lock; plus a stateless `<X>DriverProbe : IDriverProbe`
|
||||
backing the AdminUI Test Connect button.
|
||||
8. **Teardown** — one shared `TeardownAsync` used by both `ShutdownAsync`
|
||||
and `DisposeAsync`: cancel loops, await them, dispose CTSs, clear
|
||||
caches, dispose the transport.
|
||||
|
||||
Modbus adds the most advanced read planner in the fleet: block-read
|
||||
coalescing (`MaxReadGap`), auto-prohibition of failing ranges with
|
||||
bisection re-probing (`ModbusDriver.cs:687-795, 553-677`), chunking over
|
||||
per-device caps, per-register bit-RMW locks, deadband publish filtering,
|
||||
and `WriteOnChangeOnly` suppression with read-side invalidation.
|
||||
|
||||
### Per-driver conformance matrix
|
||||
|
||||
| Aspect | Modbus (ref) | S7 | AbCip | AbLegacy | TwinCAT | FOCAS | OpcUaClient |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| LOC (driver proj) | 2,374 | 1,979 | 3,762 | 1,830 | 2,261 | 5,007 | 2,347 |
|
||||
| `IPerCallHostResolver` | yes | **no** | yes | yes | yes | yes | no (single endpoint, defensible) |
|
||||
| Extra capabilities | — | — | `IAlarmSource` | — | `IRediscoverable` | `IAlarmSource` | `IAlarmSource`, `IHistoryProvider` |
|
||||
| Subscription model | PollGroupEngine | **bespoke forked loop** | PollGroupEngine | PollGroupEngine | native ADS push (default) + PollGroupEngine fallback | PollGroupEngine | **real OPC UA MonitoredItems** |
|
||||
| `onError` sink passed to engine | no | n/a | no | no | no | no | n/a |
|
||||
| Reconnect story | transport auto-reconnect + geometric backoff | **none** | evict + recreate handle, no backoff | **no eviction on data path** | lazy per-call reconnect, **native subs orphaned** | lazy reconnect per tick, no backoff | `SessionReconnectHandler` + re-arm (best) |
|
||||
| Read batching | block coalescing + bisection | per-tag | per-tag; UDT planner **half-built** | per-tag | per-tag (no ADS sum-read) | per-address, single-flight socket | batched `ReadValueIdCollection` |
|
||||
| Deadband / WriteOnChangeOnly | yes / yes | no / no | no / no | no / no | no / no | no / no | n/a (passthrough) |
|
||||
| Bit-level writes | RMW + per-register lock | native S7 bit write | RMW + per-parent lock (DINT-only assumption) | RMW + per-parent lock, width-aware | RMW + per-parent lock (**4-byte width assumption unverified**) | n/a (read-only backend) | n/a |
|
||||
| Health via `Volatile`/`volatile` | yes | **plain field** | **plain field** | `volatile` | **plain field** | yes | **plain field** |
|
||||
| `InitializeAsync` honours configJson | **no** | yes | yes | **no** | yes (empty-config quirk) | yes | yes |
|
||||
| Factory `Register` takes `ILoggerFactory` | yes | **no** | **no** | yes | **no** | **no** | yes |
|
||||
| Options `Validate()` | no (none in fleet) | no | no | no | no | no | no |
|
||||
| Live browse / discovery | declared tags only | declared tags only | declared + opt-in controller browse + UDT fan-out | declared only (correct for PCCC) | declared + opt-in symbol browse + expander | declared + capability-gated fixed tree | full recursive remote browse |
|
||||
| CLI harness | yes | yes | yes | yes | yes (+`browse`) | yes | **none** |
|
||||
|
||||
---
|
||||
|
||||
## Findings
|
||||
|
||||
Severity scale: **Critical** = live data-loss/unavailability in a realistic
|
||||
field scenario; **High** = correctness or stability defect likely to bite;
|
||||
**Medium** = architectural debt / divergence with concrete failure modes;
|
||||
**Low** = hygiene.
|
||||
|
||||
### 1. Stability
|
||||
|
||||
**STAB-1 — Critical — S7 has no reconnect path at all.**
|
||||
`Plc.OpenAsync` is called exactly once, in `InitializeAsync`
|
||||
(`Driver.S7/S7Driver.cs:165-167`). Read/write failures set `Degraded` and
|
||||
keep using the dead `Plc` (`S7Driver.cs:479-483, 995-1001`); the probe loop
|
||||
detects the outage, transitions `Stopped` (`S7Driver.cs:1351-1383`) — and
|
||||
keeps probing the same dead connection forever. A transient PLC reboot or
|
||||
network blip permanently kills the driver instance until an external
|
||||
redeploy/restart. Every sibling driver has *some* recovery path (Modbus:
|
||||
transport-level reconnect+backoff; TwinCAT/FOCAS: lazy recreate; AbCip:
|
||||
handle eviction). *Recommendation:* on socket-level `PlcException`, dispose
|
||||
and lazily reopen the `Plc` under `_gate` (mirror TwinCAT's
|
||||
`EnsureConnectedAsync`), or let the probe loop own recycle-on-Stopped.
|
||||
Note the test suite has **zero reconnect tests for S7** (see TEST-1), which
|
||||
is how this shipped.
|
||||
|
||||
**STAB-2 — Critical — TwinCAT native subscriptions are silently orphaned
|
||||
after reconnect.** Native ADS notifications are the *default* mode
|
||||
(`Driver.TwinCAT.Contracts/TwinCATDriverOptions.cs:31`). When
|
||||
`EnsureConnectedAsync` discards a stale client
|
||||
(`Driver.TwinCAT/TwinCATDriver.cs:615-619`), `AdsTwinCATClient.Dispose`
|
||||
clears its notification table (`AdsTwinCATClient.cs:422-436`) and nothing
|
||||
re-runs `AddNotificationAsync` on the replacement client — all subscribed
|
||||
data flow stops with no Bad status and no error. Compounding: the fast path
|
||||
keys on `IsConnected` (`TwinCATDriver.cs:606`), which reflects the local
|
||||
AMS port, not wire liveness, so the replacement path may never even
|
||||
trigger; and a probe failure never recycles the client
|
||||
(`TwinCATDriver.cs:542-544`). *Recommendation:* record registration intent
|
||||
(symbol, interval, handler) per subscription and replay it whenever the
|
||||
per-device client instance changes; make probe-detected outage force a
|
||||
client recycle.
|
||||
|
||||
**STAB-3 — High — Modbus transport desynchronizes after a response
|
||||
timeout.** `SendAsync` reconnect-retries only on `IsSocketLevelFailure`
|
||||
(`Driver.Modbus/ModbusTcpTransport.cs:155-165, 257-261`). A per-op timeout
|
||||
surfaces as `OperationCanceledException` — not socket-level — so the socket
|
||||
is kept with a half-read (or still-in-flight) response. The next
|
||||
transaction then reads the stale response, hits the TxId mismatch check
|
||||
(`ModbusTcpTransport.cs:233-235`), and throws `InvalidDataException` —
|
||||
which is *also* not socket-level, so the stream is still not torn down.
|
||||
The single-flight connection stays permanently desynchronized until a real
|
||||
socket error or the (optional, default-off) idle-disconnect fires.
|
||||
*Recommendation:* treat per-op timeout and any framing violation
|
||||
(TxId mismatch, truncated header) as connection-fatal: tear down the socket
|
||||
before propagating.
|
||||
|
||||
**STAB-4 — High — AbCip runs concurrent operations on shared libplctag
|
||||
handles with no serialization.** `ReadSingleAsync` / `ReadGroupAsync` /
|
||||
`WriteAsync` execute Read→GetStatus→Decode and Encode→Write→GetStatus on
|
||||
cached `IAbCipTagRuntime` instances with zero locking
|
||||
(`Driver.AbCip/AbCipDriver.cs:544-575, 614-637, 708-726`) while the server
|
||||
read path, every poll-group loop, and the alarm-projection loop
|
||||
(`AbCipAlarmProjection.cs:228`) can hit the same handle concurrently.
|
||||
AbLegacy documents this exact hazard and guards it with a per-runtime
|
||||
operation lock (“A libplctag Tag handle is not safe for concurrent
|
||||
Read/GetStatus/DecodeValue”, `Driver.AbLegacy/AbLegacyDriver.cs:775-787`);
|
||||
the fix never propagated to AbCip. Consequences: torn/wrong values decoded
|
||||
with Good status, cross-attributed `GetStatus` results.
|
||||
*Recommendation:* port AbLegacy's `GetRuntimeLock` pattern to AbCip.
|
||||
|
||||
**STAB-5 — High — FOCAS fixed-tree caches are plain `Dictionary`s mutated
|
||||
across threads.** `LastFixedSnapshots` / `LastServoLoads` /
|
||||
`LastSpindleLoads` / `LastTimers` (`Driver.FOCAS/FocasDriver.cs:1184-1194`)
|
||||
are written by `FixedTreeLoopAsync` (`FocasDriver.cs:752, 793, 836-839`)
|
||||
while `TryReadFixedTree` reads them on `ReadAsync` callers' threads
|
||||
(`FocasDriver.cs:912, 920, 945`). Concurrent write-during-read on
|
||||
`Dictionary<K,V>` is undefined behaviour (corruption or throw on resize).
|
||||
The neighbouring `_parsedAddressesByTagName` already got the
|
||||
`ConcurrentDictionary` treatment (`FocasDriver.cs:40`); these did not.
|
||||
*Recommendation:* convert to `ConcurrentDictionary` or swap immutable
|
||||
snapshots.
|
||||
|
||||
**STAB-6 — High — AbLegacy probe-loop shutdown race; S7 unsubscribe
|
||||
race.** AbLegacy discards the probe task at spawn (`_ = Task.Run(...)`,
|
||||
`Driver.AbLegacy/AbLegacyDriver.cs:118`) and `ShutdownAsync` cancels then
|
||||
immediately disposes the CTS and runtimes without awaiting loop exit
|
||||
(`AbLegacyDriver.cs:156-170`) — a winding-down loop can raise
|
||||
`OnHostStatusChanged` after shutdown and touch a disposed CTS. AbCip fixed
|
||||
this identical bug (retained `ProbeTask` + phased shutdown,
|
||||
`Driver.AbCip/AbCipDriver.cs:295-298, 332-365`); the fix never propagated
|
||||
back. S7 has the mirror-image bug in `UnsubscribeAsync`: cancel + dispose
|
||||
the CTS without draining `PollTask` (`Driver.S7/S7Driver.cs:1185-1193`),
|
||||
so `Task.Delay` on the disposed source can throw `ObjectDisposedException`
|
||||
that the loop only catches as OCE (`S7Driver.cs:1233-1234`) —
|
||||
`PollGroupEngine.StopState` exists precisely to prevent this
|
||||
(`PollGroupEngine.cs:99-112, 136`). *Recommendation:* await the loop task
|
||||
(bounded) before disposing the CTS in both places.
|
||||
|
||||
**STAB-7 — High — `InitializeAsync` ignores `driverConfigJson` in Modbus
|
||||
and AbLegacy.** AbLegacy's `_options` is readonly ctor state and
|
||||
`Initialize/Reinitialize` never parse the parameter
|
||||
(`Driver.AbLegacy/AbLegacyDriver.cs:16, 78-153`); Modbus likewise uses only
|
||||
ctor options (`Driver.Modbus/ModbusDriver.cs:167-197`). S7, AbCip and
|
||||
TwinCAT deliberately re-parse (`Driver.AbCip/AbCipDriver.cs:229-236`
|
||||
documents “rather than being silently discarded”). Any config change
|
||||
delivered through `ReinitializeAsync` on a live instance is silently
|
||||
discarded for these two drivers. TwinCAT has a related quirk: a parsed
|
||||
config with zero devices AND zero tags is silently discarded in favour of
|
||||
ctor options (`Driver.TwinCAT/TwinCATDriver.cs:89-94`), so an intentional
|
||||
“empty the driver” redeploy is ignored. *Recommendation:* make config
|
||||
re-parse on initialize a template requirement; add a consuming test per
|
||||
driver.
|
||||
|
||||
**STAB-8 — Medium — No reconnect backoff anywhere except the Modbus
|
||||
transport and S7's poll loop.** A dead device pays: AbCip — a full `Tag`
|
||||
create + Forward Open per tag per poll tick (evict-recreate,
|
||||
`AbCipDriver.cs:891-897`); FOCAS — a full two-socket reconnect attempt per
|
||||
tick of *each* of three loops (`Driver.FOCAS/FocasDriver.cs:1089-1120`);
|
||||
TwinCAT — a connect attempt per call (`TwinCATDriver.cs:602-651`).
|
||||
`PollGroupEngine` itself polls at a fixed cadence regardless of failures —
|
||||
S7's bespoke loop is the only subscriber-side backoff in the fleet
|
||||
(`S7Driver.cs:1284-1293`, capped 30 s). *Recommendation:* add optional
|
||||
failure backoff to `PollGroupEngine` (adopt S7's `ComputeBackoffDelay`)
|
||||
and a small connect-attempt throttle to the lazy-reconnect drivers.
|
||||
|
||||
**STAB-9 — Medium — `PollGroupEngine`'s `onError` sink is dead code: no
|
||||
driver passes it.** All five consumers construct the engine without the
|
||||
error callback (`ModbusDriver.cs:115`, `AbCipDriver.cs:133`,
|
||||
`AbLegacyDriver.cs:65`, `TwinCATDriver.cs:69`, `FocasDriver.cs:72`), so a
|
||||
reader exception in a poll tick (e.g. `RequireTransport` after teardown,
|
||||
reader contract violation) is silently swallowed — the precise
|
||||
“silently-broken subscription” failure mode the engine's own doc comment
|
||||
warns about (`PollGroupEngine.cs:21-25, 164-169`). *Recommendation:* pass
|
||||
`onError` routing to the driver's health surface + logger in all five;
|
||||
consider making the parameter required.
|
||||
|
||||
**STAB-10 — Medium — AbLegacy never evicts failed tag handles on the data
|
||||
path.** Non-zero status and transport exceptions leave the cached runtime
|
||||
in place (`AbLegacyDriver.cs:246-259, 269-274`) with no recreate-on-failure
|
||||
(contrast `AbCipDriver.cs:891-897` and AbLegacy's own probe loop at
|
||||
`AbLegacyDriver.cs:456-461`). A handle invalidated by a PLC state change
|
||||
pins the tag Bad until full driver reinit.
|
||||
|
||||
**STAB-11 — Medium — RMW locks don't cover direct parent writes (AbCip,
|
||||
AbLegacy, TwinCAT).** The per-parent semaphore serializes bit-RMW callers
|
||||
against each other, but a direct write to the parent word/DINT itself goes
|
||||
through the normal write path without taking the same lock
|
||||
(`AbCipDriver.cs:708-710` vs `787-818`; same shape in AbLegacy and
|
||||
TwinCAT), so it can interleave with an in-flight read-modify-write → lost
|
||||
update. Additionally AbCip's RMW hard-codes DINT while `AbCipTagPath`
|
||||
accepts `.N` on any parent (`Driver.AbCip/AbCipTagPath.cs:17-19`), and
|
||||
TwinCAT's RMW always accesses the parent as 4-byte `UDInt` — explicitly
|
||||
flagged as an unverified assumption that will likely fail
|
||||
`DeviceInvalidSize` on WORD/BYTE parents on real hardware
|
||||
(`Driver.TwinCAT/AdsTwinCATClient.cs:96-107`).
|
||||
|
||||
**STAB-12 — Medium — Blocking/uncancellable teardown paths.** FOCAS's sync
|
||||
`Dispose()` performs a close-PDU exchange (`SendPdu` + `ReadPdu`) on a
|
||||
`NetworkStream` with no ReadTimeout and no token
|
||||
(`Driver.FOCAS/Wire/FocasWireClient.cs:164-175`) — a stalled CNC can wedge
|
||||
`ShutdownAsync` indefinitely. TwinCAT's `ConnectAsync` calls the blocking
|
||||
SDK `Connect` and ignores both its token and timeout parameter for the
|
||||
connect itself (`AdsTwinCATClient.cs:73-80`).
|
||||
|
||||
**STAB-13 — Positive.** Write-outcome surfacing — the seam the server's
|
||||
value-revert self-correction depends on — is honoured by every protocol
|
||||
driver: per-item `WriteResult` with typed exception→status mapping (Modbus
|
||||
`ModbusDriver.cs:897-942`; S7 `S7Driver.cs:921-1006` including the
|
||||
PUT/GET-denied → `Faulted` escalation; AbCip `AbCipDriver.cs:732-775`;
|
||||
AbLegacy `AbLegacyDriver.cs:348-366`; TwinCAT `TwinCATDriver.cs:332-349`
|
||||
with a numerically-verified ADS error table; OpcUaClient passes upstream
|
||||
codes verbatim and distinguishes post-dispatch cancellation as
|
||||
`BadTimeout` "outcome unknown" — `OpcUaClientDriver.cs:753-779`, the most
|
||||
careful non-idempotency handling in the fleet). Nothing is
|
||||
fire-and-forget except the AbCip alarm *acknowledge* path (UNDER-5).
|
||||
OpcUaClient's reconnect state machine (double-arm guard, re-arm on
|
||||
exhaustion, session swap under `_probeLock` —
|
||||
`OpcUaClientDriver.cs:1908-2058`) is the strongest reconnect
|
||||
implementation in the subsystem.
|
||||
|
||||
### 2. Performance
|
||||
|
||||
**PERF-1 — High — Five of seven drivers read one tag per wire round-trip.**
|
||||
Only Modbus (block coalescing + auto-prohibition/bisection,
|
||||
`ModbusDriver.cs:687-795`) and OpcUaClient (single
|
||||
`ReadValueIdCollection` per batch, `OpcUaClientDriver.cs:640-666`) batch.
|
||||
The others are O(N) round trips per poll tick on serialized links:
|
||||
- S7: one PDU per tag under one `_gate`d connection
|
||||
(`S7Driver.cs:443-484`); S7comm multi-var reads (S7.Net
|
||||
`ReadMultipleVarsAsync`) unused.
|
||||
- TwinCAT: one ADS call per symbol; ADS **sum-read** — Beckhoff's
|
||||
documented batching mechanism — unused (`TwinCATDriver.cs:203-248`).
|
||||
- FOCAS: one request/response per address on a strictly single-flight
|
||||
socket (`SynchronizedFocasClient.cs:34`); `pmc_rdpmcrng` supports ranges
|
||||
but each read fetches exactly one value's width
|
||||
(`Wire/WireFocasClient.cs:296-334`). Throughput ceiling ≈ tags × RTT.
|
||||
- AbLegacy: one PCCC transaction per scalar; no span coalescing of
|
||||
adjacent file elements (N7:0..N7:2), the natural Modbus analog.
|
||||
*Recommendation:* per-protocol batching is the single biggest scalability
|
||||
lever; priority order = TwinCAT sum-read, S7 multi-var, FOCAS PMC range
|
||||
reads, AbLegacy spans.
|
||||
|
||||
**PERF-2 — High — AbCip's whole-UDT batched read exists in two halves that
|
||||
were never joined.** The read planner
|
||||
(`Driver.AbCip/AbCipUdtReadPlanner.cs:32-100`) can collapse N members into
|
||||
one parent read, but only via declaration-order layout — off by default
|
||||
because it produces “silently-plausible wrong numbers” when member order
|
||||
diverges (`AbCipDriverOptions.cs:60-72`). Meanwhile the driver already
|
||||
fetches and caches *true* member offsets via the CIP Template Object
|
||||
(`AbCipDriver.cs:151-182`, `AbCipTemplateCache.cs`) — used only for
|
||||
discovery, never consulted by the planner
|
||||
(`AbCipUdtMemberLayout.cs:22-24` even points at the richer path). Net: in
|
||||
every safe configuration, N UDT members = N CIP round trips per tick.
|
||||
*Recommendation:* feed `AbCipUdtShape` offsets into the planner; retire
|
||||
the unsafe declaration-order mode.
|
||||
|
||||
**PERF-3 — High — S7's forked poll loop diffs arrays by reference.**
|
||||
`PollOnceAsync` compares `Equals(lastSeen?.Value, current.Value)`
|
||||
(`S7Driver.cs:1304`); array reads return a fresh CLR array every poll, so
|
||||
every subscribed array tag fires `OnDataChange` **every tick**, flooding
|
||||
the dependency mux/DPS downstream. `PollGroupEngine` fixed exactly this
|
||||
with `StructuralComparisons` (`PollGroupEngine.cs:203-209`); the bespoke
|
||||
S7 copy never picked it up. (Also a Conventions finding — see CONV-1.)
|
||||
|
||||
**PERF-4 — Medium — OpcUaClient serializes all traffic on one global
|
||||
`SemaphoreSlim(1,1)`.** Reads, writes, discovery, subscription creates,
|
||||
acks and HistoryReads all queue on `_gate`
|
||||
(`OpcUaClientDriver.cs:78, 626, 713, 836, 1186, 1377, 1451, 1653, 1825`).
|
||||
OPC UA sessions multiplex service calls natively; a multi-second
|
||||
HistoryRead or full re-discovery blocks every live read/write behind it.
|
||||
The gate is only needed for session-swap consistency, which `_probeLock` +
|
||||
re-read-inside-critical-section already provide.
|
||||
|
||||
**PERF-5 — Medium — OpcUaClient discovery enrichment issues one giant
|
||||
un-chunked Read and silently degrades on rejection.**
|
||||
`EnrichAndRegisterVariablesAsync` sends `pending.Count * 4` ReadValueIds in
|
||||
a single call (`OpcUaClientDriver.cs:1029-1046`) — 40k operations for a
|
||||
10k-node server. A server enforcing `MaxNodesPerRead` returns
|
||||
`BadTooManyOperations`; the blanket catch (`:1049-1057`) then registers
|
||||
*every* variable as Int32/ViewOnly/non-historized with no log or health
|
||||
signal. *Recommendation:* chunk client-side; log the fallback.
|
||||
|
||||
**PERF-6 — Low — Assorted per-tick allocation churn.** TwinCAT re-parses
|
||||
`TwinCATSymbolPath` on every read/write/subscribe call — lists +
|
||||
StringBuilder per tag per tick (`TwinCATDriver.cs:220, 284, 468`); S7
|
||||
caches parses (`_parsedByName`) and is the model. Modbus publishes a fresh
|
||||
`DriverHealth` record per successful tag read (`ModbusDriver.cs:285`).
|
||||
AbCip's `BuildArray<T>` boxes per element
|
||||
(`LibplctagTagRuntime.cs:107-118`). None are hot enough to be urgent.
|
||||
|
||||
**PERF-7 — Low — Deadband exists only in Modbus.** The publish-suppression
|
||||
deadband (`ModbusDriver.cs:136-159`) and `WriteOnChangeOnly` never
|
||||
generalized; noisy analog CNC/PLC signals on the other five poll-based
|
||||
drivers publish every jitter. The mechanism is driver-agnostic and belongs
|
||||
in or beside `PollGroupEngine`.
|
||||
|
||||
### 3. Conventions
|
||||
|
||||
**CONV-1 — High — S7 forked the poll loop instead of using
|
||||
`PollGroupEngine`, and the fork has diverged in both directions.**
|
||||
`PollGroupEngine`'s own doc claims it serves “Modbus, AB CIP, S7, FOCAS”
|
||||
(`PollGroupEngine.cs:8-9`) but S7 re-ships the entire loop
|
||||
(`S7Driver.cs:1165-1307`). The fork is *better* in two ways the engine
|
||||
never absorbed (capped exponential failure backoff `:1284-1293`; poll
|
||||
failures logged + degrade health `:1258-1274`) and *worse* in two ways the
|
||||
engine already fixed (reference-equality array diff — PERF-3; CTS disposed
|
||||
without draining the loop — STAB-6). This is the textbook cost of template
|
||||
divergence: each side owns fixes the other needs. *Recommendation:*
|
||||
extend `PollGroupEngine` with backoff + onError-driven health, then move
|
||||
S7 onto it and delete the fork.
|
||||
|
||||
**CONV-2 — High — Three different strictness tiers parse the same config,
|
||||
and the most-used one is the most permissive.** For every driver the same
|
||||
enum field is parsed three ways:
|
||||
- **Factory**: string-typed DTO + fail-loud `ParseEnum` listing valid
|
||||
values (`ModbusDriverFactoryExtensions.cs:191-201` and equivalents) —
|
||||
deliberate, good.
|
||||
- **Probe**: deserializes with `JsonStringEnumConverter`; Modbus's probe
|
||||
binds the *runtime options type* (`ModbusDriverOptions`, TimeSpan
|
||||
fields) rather than the factory DTO shape
|
||||
(`ModbusDriverProbe.cs:19-24, 36`), so a DB config with `timeoutMs`
|
||||
silently loses its timeout in the probe and tag-shape mismatches can
|
||||
make the probe reject/misread a config the factory accepts. OpcUaClient
|
||||
documents probe/factory parse-parity as the rule
|
||||
(`OpcUaClientDriverProbe.cs:22`); Modbus violates it.
|
||||
- **Equipment-tag parser**: silent fallback — an unknown or typo'd
|
||||
`dataType` string quietly becomes the default type (`Int16`/`DInt`/
|
||||
`Int`/`Int32` per driver) instead of a rejection
|
||||
(`Driver.Modbus.Contracts/ModbusEquipmentTagParser.cs:65-67`,
|
||||
`S7EquipmentTagParser.cs:59-61`, `AbCipEquipmentTagParser.cs:86-88`,
|
||||
`AbLegacyEquipmentTagParser.cs:60-62`,
|
||||
`TwinCATEquipmentTagParser.cs:47-49`,
|
||||
`FocasEquipmentTagParser.cs:43-45`). A mis-typed equipment tag reads and
|
||||
writes the wrong width with Good status. The identical private
|
||||
`ReadEnum` helper is copy-pasted six times instead of living beside
|
||||
`EquipmentTagRefResolver` in Core.Abstractions.
|
||||
*Recommendation:* hoist a shared strict `ReadEnum` (present-but-invalid ⇒
|
||||
parse failure ⇒ `BadNodeIdUnknown`); make probes parse the factory DTO.
|
||||
|
||||
**CONV-3 — Medium — Factory registration asymmetry leaves four drivers
|
||||
logging to `NullLogger` in production.** S7, TwinCAT, AbCip and FOCAS
|
||||
`Register(registry)` take no `ILoggerFactory`
|
||||
(`S7DriverFactoryExtensions.cs:19`, `TwinCATDriverFactoryExtensions.cs:18`,
|
||||
`AbCipDriverFactoryExtensions.cs:21`, `FocasDriverFactoryExtensions.cs:36`)
|
||||
even though all four driver classes accept a logger; the bootstrap
|
||||
consequently can't pass one
|
||||
(`Host/Drivers/DriverFactoryBootstrap.cs:100-107`). All the warning paths
|
||||
those drivers carefully log (poll failures, prohibitions, live-risk
|
||||
assumptions) are invisible in production. Modbus/AbLegacy/OpcUaClient/
|
||||
Galaxy have the logger-aware overload. *Recommendation:* one-line fix per
|
||||
factory; align all eight `Register` signatures.
|
||||
|
||||
**CONV-4 — Medium — `ResolveHost` mis-keys equipment-tag references in
|
||||
every multi-device driver.** The implementations consult only
|
||||
`_tagsByName` (authored names); an equipment-tag TagConfig-JSON reference
|
||||
never matches and falls back to the driver-level/first-device host
|
||||
(`AbCipDriver.cs:483-488`, `AbLegacyDriver.cs:496-501`,
|
||||
`TwinCATDriver.cs:585-592`, `FocasDriver.cs:1082-1087`; Modbus
|
||||
`ModbusDriver.cs:125-131` has the same shape but per-tag `UnitId` isn't in
|
||||
the equipment parser anyway). Per-host Polly bulkhead/circuit-breaker keys
|
||||
are therefore wrong for equipment tags on multi-device instances — a
|
||||
broken device B can trip device A's breaker and vice versa. Since
|
||||
equipment tags are the *primary* authoring model post-Galaxy-standardization,
|
||||
the per-call host resolver is effectively inert for the flagship path.
|
||||
*Recommendation:* route `ResolveHost` through
|
||||
`EquipmentTagRefResolver` and derive the host from the parsed def
|
||||
(`deviceHostAddress` / `unitId`).
|
||||
|
||||
**CONV-5 — Medium — Health-field publication conventions drift.** The
|
||||
template documents `Volatile.Read/Write` (`ModbusDriver.cs:217-228`);
|
||||
FOCAS conforms (`FocasDriver.cs:43-46`), AbLegacy uses a `volatile` field
|
||||
with rationale (`AbLegacyDriver.cs:29-34`), while S7
|
||||
(`S7Driver.cs:124, 1272`), AbCip (`AbCipDriver.cs:95`), TwinCAT and
|
||||
OpcUaClient (`OpcUaClientDriver.cs:96`) use plain fields. Immutable record
|
||||
swap means no torn reads — this is a visibility/staleness nit — but four
|
||||
different idioms for the same one-line concern is exactly the divergence
|
||||
this review is hunting. Related one-offs: TwinCAT declares `Healthy` at
|
||||
init with zero wire contact (`TwinCATDriver.cs:115`) where every other
|
||||
driver proves the connection first; TwinCAT reads host state without its
|
||||
probe lock (`TwinCATDriver.cs:524-525`); AbLegacy never refreshes health
|
||||
on successful writes (`AbLegacyDriver.cs:344-346` vs
|
||||
`AbCipDriver.cs:725`); AbLegacy silently clobbers duplicate tag names
|
||||
(`AbLegacyDriver.cs:91`) where AbCip fails fast
|
||||
(`AbCipDriver.cs:249-256`).
|
||||
|
||||
**CONV-6 — Medium — Contracts layering is uniform except OpcUaClient,
|
||||
which drags the whole OPC UA SDK into its Contracts.** Six Contracts
|
||||
projects are POCO options + equipment-tag parser (FOCAS's csproj even
|
||||
enforces “NO PackageReference. NO ProjectReference.”), but
|
||||
`Driver.OpcUaClient.Contracts` carries a full OPC UA stack reference
|
||||
because `NamespaceMap` lives there
|
||||
(`OpcUaClient.Contracts.csproj:9`) — every consumer wanting just the
|
||||
options DTO (probe hosts, AdminUI editors, Browser) transitively loads the
|
||||
SDK. This is the previously-deferred OpcUaClient.Contracts-002 finding;
|
||||
it remains the right call to move `NamespaceMap` out. Minor: all Contracts
|
||||
projects use the parent namespace without a `.Contracts` suffix —
|
||||
consistent, just surprising.
|
||||
|
||||
**CONV-7 — Low/Medium — CLI harness divergences.** The six CLIs share
|
||||
`DriverCommandBase` + `SnapshotFormatter` and a uniform
|
||||
probe/read/write/subscribe verb set, but: `ParseValue`/`ParseBool` is
|
||||
copy-pasted six times (`*/Commands/WriteCommand.cs`), option validation is
|
||||
a different shape in every base and **absent entirely in AbLegacy**
|
||||
(`AbLegacyCommandBase.cs` — `--timeout-ms 0` reaches the driver), the
|
||||
abstract `Timeout` init-setter is a silent no-op in five bases but throws
|
||||
`NotSupportedException` in AbCip (`AbCipCommandBase.cs:43`), and only
|
||||
TwinCAT exposes a `browse` command despite AbCip also supporting
|
||||
controller browse (hardcoded `EnableControllerBrowse=false`,
|
||||
`AbCipCommandBase.cs:63`). OpcUaClient has no CLI at all (the server-side
|
||||
Client.CLI tests the *server*, not this driver against a third-party
|
||||
endpoint). *Recommendation:* hoist value parsing + a standard `Validate()`
|
||||
into Cli.Common; add an OpcUaClient CLI or document the gap.
|
||||
|
||||
**CONV-8 — Low — Dead/no-op config knobs and stale scaffold docs.**
|
||||
`DisableFC23` is a documented no-op (`ModbusDriverOptions.cs:83-89`);
|
||||
AbCip `ConnectionSize` is plumbed but never applied
|
||||
(`AbCipDriverOptions.cs:97-103`); AbLegacy's class doc still says
|
||||
read/write “ship in PRs 2 and 3” (`AbLegacyDriver.cs:9-11`); OpcUaClient's
|
||||
header still says “PR 66 ships the scaffold: IDriver only”
|
||||
(`OpcUaClientDriver.cs:12-14`). No options class in the fleet has a
|
||||
`Validate()` method — validation is scattered across factory throws and
|
||||
init guards (S7's init guards are the best of breed,
|
||||
`S7Driver.cs:314-385`).
|
||||
|
||||
### 4. Underdeveloped areas
|
||||
|
||||
**UNDER-1 — High — FOCAS advertises writes it cannot perform.** The only
|
||||
real backend returns `BadNotWritable` for every address
|
||||
(`Wire/WireFocasClient.cs:71-73`), yet `FocasTagDefinition.Writable`
|
||||
defaults `true` with a doc-comment citing write tests
|
||||
(`FOCAS.Contracts/FocasDriverOptions.cs:152-158`) and
|
||||
`FocasEquipmentTagParser` hard-codes `Writable: true`
|
||||
(`FocasEquipmentTagParser.cs:35`) — so equipment tags can surface as
|
||||
Operate-writable OPC UA nodes whose writes always fail. The driver-side
|
||||
write status mapping (`FocasDriver.cs:353-381`) is currently dead code.
|
||||
*Recommendation:* either implement PMC writes in the wire client or force
|
||||
`Writable:false` at the parser/discovery seams until it exists.
|
||||
|
||||
**UNDER-2 — High — OpcUaClient's `UnsMappingTable` is validated as
|
||||
mandatory but consumed nowhere.** `ValidateNamespaceKind` hard-fails
|
||||
Equipment-kind configs lacking a mapping table
|
||||
(`OpcUaClientDriver.cs:330-355`), yet no code reads the table —
|
||||
`DiscoverAsync` builds the local tree purely from remote browse structure
|
||||
(`:824-1105`). Operators are forced to author config with zero runtime
|
||||
effect; the promised remote→UNS remapping feature does not exist.
|
||||
*Recommendation:* delete the gate or build the feature.
|
||||
|
||||
**UNDER-3 — High — OpcUaClient alarm source-node filtering compares
|
||||
mismatched NodeId encodings.** `OnEventNotification` filters
|
||||
session-relative `ns=N;…` renderings against caller-supplied refs by
|
||||
ordinal string compare (`OpcUaClientDriver.cs:1337, 1538-1539`), while the
|
||||
driver's own persisted references are stable `nsu=…` strings
|
||||
(`:808-809`). Any non-empty filter built from stored refs silently drops
|
||||
every event; only empty-filter subscriptions work today.
|
||||
|
||||
**UNDER-4 — Medium — S7 feature gaps make authored array tags
|
||||
unreachable.** `S7TagDto` has no `ArrayCount` field and `BuildTag` never
|
||||
sets it (`S7DriverFactoryExtensions.cs:80-90, 137-151`) — driver-config
|
||||
array tags are silently scalar; arrays only work as equipment tags
|
||||
(`S7EquipmentTagParser.cs:73-87`). Array *writes* are unsupported
|
||||
(`S7Driver.cs:1014-1017`), wide-type arrays are a stated follow-up
|
||||
(`:861-862`), and `UInt32` surfaces lossily as `Int32`
|
||||
(`S7Driver.cs:1149-1152`).
|
||||
|
||||
**UNDER-5 — Medium — AbCip alarm acknowledge outcomes are discarded.**
|
||||
`AcknowledgeAsync` fire-and-forgets the write results
|
||||
(`AbCipAlarmProjection.cs:143-149`); a failed ack (undeclared `.Acked`
|
||||
member, non-writable, comms error) is invisible to the operator, health,
|
||||
and the Part 9 caller — conditions stick un-acked with no visible cause.
|
||||
Related discovery weak spots: with `EnableControllerBrowse` on, one
|
||||
unreachable PLC faults the entire multi-device `DiscoverAsync`
|
||||
(uncaught `await foreach`, `AbCipDriver.cs:970-1037`), and browsed tags
|
||||
default to writable/Operate (`CipSymbolObjectDecoder.cs:91`).
|
||||
|
||||
**UNDER-6 — Medium — Equipment-tag parsers lag the authored-tag feature
|
||||
set.** Modbus's parser ignores `unitId`, `deadband`, `stringByteOrder`,
|
||||
`writable`, `coalesceProhibited` and the address-grammar string, and
|
||||
forces `Writable: true` (`ModbusEquipmentTagParser.cs:54-57`); AbLegacy
|
||||
and FOCAS also hard-code writable-true
|
||||
(`AbLegacyEquipmentTagParser.cs:52`, `FocasEquipmentTagParser.cs:35`) —
|
||||
read-only equipment tags are unauthorable on three drivers (AbCip honours
|
||||
a `writable` key, `AbCipEquipmentTagParser.cs:53-54`). FOCAS equipment
|
||||
tags additionally bypass the `FocasCapabilityMatrix` pre-flight gate that
|
||||
authored tags get (`FocasDriver.cs:243-247` vs `:115-119`). As equipment
|
||||
tags are the primary authoring model, the parsers deserve parity plus a
|
||||
shared conformance test.
|
||||
|
||||
**UNDER-7 — Medium — Live-risk assumptions documented but ungated.**
|
||||
TwinCAT carries two explicit "unverified against real hardware" blocks on
|
||||
core paths — array read shape (`AdsTwinCATClient.cs:109-116`) and
|
||||
Flat-mode `SubSymbols` population (`:263-277`, struct members could
|
||||
silently vanish from discovery) — plus the bit-RMW width assumption
|
||||
(STAB-11). AbLegacy's array decode rests on five explicitly unproven
|
||||
layout assumptions with no PCCC fixture
|
||||
(`LibplctagLegacyTagRuntime.cs:64-88`). FOCAS's `cnc_getfigure` command id
|
||||
and servo-load scaling are sim-validated only
|
||||
(`FocasWireClient.cs:391-395, 943-947`). These are honest, well-documented
|
||||
debts — but nothing aggregates them into a "needs bench time" checklist.
|
||||
|
||||
**UNDER-8 — Medium — Test coverage is codec-heavy, failure-path-light, and
|
||||
uneven.** Approximate unit-test counts: Modbus 191 (+71 addressing),
|
||||
AbCip 234, FOCAS 143, S7 130, TwinCAT 119, AbLegacy 114, OpcUaClient 93
|
||||
(+10 browser). Integration suites are all skip-gated on fixture
|
||||
reachability; Modbus's is the richest (30 tests incl. exception injection
|
||||
and device-quirk profiles), AbLegacy's the thinnest (2), and TwinCAT's 13
|
||||
require a real TC3 XAR runtime — no docker fixture exists, so it never
|
||||
runs automated. Systemic gaps: **zero reconnect tests for S7 and
|
||||
AbLegacy** (the STAB-1/STAB-10 findings live exactly there), zero
|
||||
concurrency tests for S7 and AbCip (STAB-4 lives there; only AbLegacy has
|
||||
a dedicated runtime-concurrency suite), and OpcUaClient has the widest
|
||||
feature surface on the lowest test density (though its failure-path
|
||||
*spread* — reconnect, failover, stale-session race, continuation points —
|
||||
is the best). `Cli.Common`'s `DriverCommandBase` has no direct tests.
|
||||
There is **zero TODO/HACK/FIXME/NotImplementedException debt** in the
|
||||
subsystem — all `NotSupportedException` sites are deliberate fail-fast
|
||||
gates with handlers.
|
||||
|
||||
**UNDER-9 — Low — OpcUaClient is a 2,154-line monolith.** Six separable
|
||||
responsibilities share one file (config/PKI/identity, failover sweep,
|
||||
recursive discovery, live subscriptions, alarms/acks, the full
|
||||
IHistoryProvider, plus the reconnect state machine). The reconnect state
|
||||
machine is the trickiest concurrency in the subsystem and deserves
|
||||
extraction for isolated testability. Also: subscription transfer rests on
|
||||
the *unset* SDK default `TransferSubscriptionsOnReconnect`
|
||||
(`OpcUaClientDriver.cs:1946-1949`), and `BuildCertificateIdentity` only
|
||||
loads PKCS#12 while the options doc promises PEM support
|
||||
(`:479-484` vs `OpcUaClientDriverOptions.cs:68-75`).
|
||||
|
||||
---
|
||||
|
||||
## Maturity ratings
|
||||
|
||||
| Dimension | Rating (1-5) | Justification |
|
||||
|---|---|---|
|
||||
| Stability | **3** | The template (Modbus transport + OpcUaClient reconnect machine) is genuinely robust and write-outcome surfacing is universal, but two Critical connection-loss failures (S7 no-reconnect, TwinCAT orphaned native subs), an AbCip handle-concurrency hazard, and fleet-wide missing backoff mean a routine PLC power-cycle defeats half the fleet. |
|
||||
| Performance | **2** | Only 2 of 7 drivers batch reads; the rest are O(N) serialized round trips per poll tick, AbCip's flagship UDT batching is half-built, and deadband exists only in Modbus — the subsystem scales by tag count, not by request count. |
|
||||
| Conventions | **3** | The shared seams (PollGroupEngine, EquipmentTagRefResolver, factory/probe pattern, StatusMapper) are real and mostly followed, but the S7 poll-loop fork, three-tier config-parse strictness, six-way `ReadEnum` copy-paste, logger-registration asymmetry, and drifting health idioms show fixes not flowing back to the template. |
|
||||
| Underdeveloped areas | **3** | Zero stub/TODO debt and honest live-risk documentation, offset by advertised-but-dead features (FOCAS writes, `UnsMappingTable`, `DisableFC23`, `ConnectionSize`), equipment-tag parsers lagging the primary authoring model, and reconnect/concurrency test gaps precisely where the Critical bugs live. |
|
||||
|
||||
---
|
||||
|
||||
## Recommended remediation order
|
||||
|
||||
1. **S7 reconnect** (STAB-1) + a reconnect test template applied to S7 and
|
||||
AbLegacy (UNDER-8) — one field scenario, two drivers, currently fatal.
|
||||
2. **TwinCAT native-subscription replay on client recycle** (STAB-2).
|
||||
3. **AbCip per-runtime operation lock** (STAB-4, port from AbLegacy) and
|
||||
**FOCAS ConcurrentDictionary caches** (STAB-5) — silent-corruption
|
||||
class.
|
||||
4. **Modbus transport: timeout/framing-violation ⇒ connection teardown**
|
||||
(STAB-3).
|
||||
5. **PollGroupEngine v2**: absorb S7's backoff + failure-health, wire
|
||||
`onError` in all five consumers, migrate S7 off its fork
|
||||
(CONV-1, STAB-9, PERF-3, STAB-8).
|
||||
6. **Shared strict equipment-tag enum parsing + writable/capability
|
||||
parity** (CONV-2, UNDER-6) — one Core.Abstractions helper, six deletions.
|
||||
7. **Per-protocol read batching** (PERF-1/PERF-2), starting with the AbCip
|
||||
planner⇄template-cache join (design already exists in-tree).
|
||||
8. Sweep the one-liners: factory `ILoggerFactory` (CONV-3), `ResolveHost`
|
||||
via resolver (CONV-4), FOCAS writable-false (UNDER-1), delete
|
||||
`UnsMappingTable` gate (UNDER-2), OpcUaClient alarm-filter encoding
|
||||
(UNDER-3).
|
||||
Reference in New Issue
Block a user