docs(archreview): 2026-07-12 re-review at f6eaa267 + 12 round-2 remediation plans
Re-ran all seven domain reviews at master f6eaa267 (reports rewritten in
place, each with a prior-finding status table): all 4 round-1 Criticals
verified closed; new top findings are the S7 connect-timeout OCE regression
(05/STAB-14), the ResilienceConfig operator-authorable brick (01/S-6), and
a batch of resilience-seam Mediums. 00-OVERALL.md carries the updated
maturity matrix + 12-item action list.
Adds R2-01..R2-12 design/implementation plans (one per action item, house
format + bite-sized TDD task breakdowns + co-located .tasks.json; 193 tasks,
~18-24 dev-days). STATUS.md updated: round-1 topology marked historical
(all merged+pushed), re-review findings table + plan pointers added.
This commit is contained in:
+286
-132
@@ -1,7 +1,16 @@
|
||||
# Architecture Review 05 — Protocol Drivers
|
||||
|
||||
**Date:** 2026-07-08
|
||||
**Commit:** `9cad9ed0`
|
||||
**Date:** 2026-07-12
|
||||
**Commit:** `f6eaa267` (master, clean tree)
|
||||
**Updates:** the 2026-07-08 review at `9cad9ed0`. Since then the arch-review
|
||||
remediation branches merged to master; the delta touching this domain is
|
||||
exactly: Critical 3 — S7 lazy reconnect (`25c0c6f6`, merged `dfc02af3`),
|
||||
Critical 4 — TwinCAT native-notification replay (`af318fb4`, merged
|
||||
`8f7b0017`), the OTOPCUA0001 analyzer wiring (`f0082af5` — pragma/NoWarn-only
|
||||
in this domain: two driver-internal self-call pragmas in
|
||||
`AbCipAlarmProjection.cs`, one in `S7Driver.PollOnceAsync`, `NoWarn` in the
|
||||
six CLI csprojs), plus the new reconnect test suites under `tests/Drivers/`.
|
||||
No other driver file changed.
|
||||
**Scope:** the seven cross-platform protocol drivers and their satellites —
|
||||
`Driver.Modbus` (+ `.Addressing`, `.Contracts`), `Driver.S7` (+ `.Contracts`),
|
||||
`Driver.AbCip` (+ `.Contracts`), `Driver.AbLegacy` (+ `.Contracts`),
|
||||
@@ -11,14 +20,57 @@
|
||||
`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`).
|
||||
**Method:** every prior finding was re-verified against current code (the
|
||||
delta above means findings in untouched files carry forward with their line
|
||||
references intact; only `S7Driver.cs` and `TwinCATDriver.cs` shifted). The
|
||||
two remediation commits were read in full and adversarially — the fresh
|
||||
findings STAB-14..17 come from that reading. The original method (Modbus as
|
||||
the exhaustively-read reference driver, structural diffing of each sibling)
|
||||
stands from the 2026-07-08 pass.
|
||||
|
||||
---
|
||||
|
||||
## Prior-finding status (9cad9ed0 → f6eaa267)
|
||||
|
||||
| ID | One-liner | Status @ f6eaa267 |
|
||||
|---|---|---|
|
||||
| STAB-1 | S7 has no reconnect path at all | **FIXED** — `25c0c6f6`: `IS7Plc`/`IS7PlcFactory` seam (`Driver.S7/IS7Plc.cs`), lazy `EnsureConnectedAsync` under `_gate` (`S7Driver.cs:1188-1224`), connection-fatal classification `MarkConnectionDeadIfFatal`/`IsS7ConnectionFatal` (`:1226-1251`), probe loop as reconnect backstop (`:1532-1536`). 4 unit guards (`S7DriverReconnectTests`) + env-gated live bounce test (`S7_1500ReconnectTests`), live-verified against python-snap7. **However, the fix itself introduces new findings STAB-14 (High) and STAB-15 (High)** — see below. |
|
||||
| STAB-2 | TwinCAT native subs silently orphaned after reconnect | **FIXED** — `af318fb4`: `NativeRegistration` replayable-intent registry per device (`TwinCATDriver.cs:551-613, 921`), `EnsureConnectedUnderGateAsync` replays every intent onto a fresh client (`:731-773, 784-823`), probe-failure `RecycleClientAsync` closes the `IsConnected`-lies compounding hole (`:631-643, 826-843`). Register and replay both run under `ConnectGate` — verified no register/replay double-registration race (intent stored only after the handle is live, `:508-520`). Unit-only verification (4 tests, fake client) — no TC3 fixture exists, accepted. **Residuals: STAB-16 (Medium) and STAB-17 (Low)** — see below. Data-path reads on a wire-dead-but-port-open client still fail Bad until the next probe tick recycles (bounded by probe interval — acceptable); the wrapper's pre-gate fast path (`:709-710`) can hand a data-path caller a client the probe is concurrently disposing (transient Bad read — acceptable). |
|
||||
| STAB-3 | Modbus transport desyncs after response timeout / TxId mismatch | **STILL OPEN** — `ModbusTcpTransport.cs:155-165, 233-237, 257-261` unchanged. Note: STAB-15 shows the new S7 reconnect path reproduces this same failure mode. |
|
||||
| STAB-4 | AbCip concurrent ops on shared libplctag handles, no lock | **STILL OPEN** — `AbCipDriver.cs:544-575, 614-637, 708-726` unchanged; alarm-projection read loop now `AbCipAlarmProjection.cs:230-232` (pragma lines shifted it +4). |
|
||||
| STAB-5 | FOCAS fixed-tree caches are plain `Dictionary`s mutated cross-thread | **STILL OPEN** — `FocasDriver.cs:1184-1194, 752-839, 912-945` unchanged. |
|
||||
| STAB-6 | AbLegacy probe-shutdown race; S7 unsubscribe race | **STILL OPEN** — AbLegacy unchanged (`AbLegacyDriver.cs:118, 156-170`); S7 `UnsubscribeAsync` still cancels + disposes the CTS without draining `PollTask` (now `S7Driver.cs:1350-1354`; the loop's `Task.Delay` still catches only OCE, `:1396-1397`). |
|
||||
| STAB-7 | `InitializeAsync` ignores `driverConfigJson` (Modbus, AbLegacy); TwinCAT empty-config quirk | **STILL OPEN** — all three sites unchanged (`ModbusDriver.cs:167-197`, `AbLegacyDriver.cs:78-153`, `TwinCATDriver.cs:89-94`). |
|
||||
| STAB-8 | No reconnect backoff fleet-wide (except Modbus transport, S7 poll loop) | **STILL OPEN — and slightly worse on S7**: the new lazy reconnect attempts a full TCP connect (bounded only by `_options.Timeout`) on *every* data call while the PLC is down, with no attempt throttle; poll-loop backoff shields subscriptions but not server-initiated reads/writes or the probe. |
|
||||
| STAB-9 | `PollGroupEngine` `onError` sink dead — no driver passes it | **STILL OPEN** — verified all five construct without it (`ModbusDriver.cs:115`, `AbCipDriver.cs:133`, `AbLegacyDriver.cs:65`, `TwinCATDriver.cs:69`, `FocasDriver.cs:72`). |
|
||||
| STAB-10 | AbLegacy never evicts failed tag handles on the data path | **STILL OPEN** — `AbLegacyDriver.cs:246-274` unchanged. |
|
||||
| STAB-11 | RMW locks don't cover direct parent writes; AbCip DINT / TwinCAT 4-byte assumptions | **STILL OPEN** — all sites unchanged (`AdsTwinCATClient.cs` untouched by the replay commit). |
|
||||
| STAB-12 | Blocking/uncancellable teardown paths (FOCAS close-PDU, TwinCAT blocking Connect) | **STILL OPEN — and compounded**: the new `RecycleClientAsync` waits on `ConnectGate` with `CancellationToken.None` (`TwinCATDriver.cs:835`), so a connect wedged under the gate now also wedges the probe loop uncancellably. |
|
||||
| STAB-13 | (Positive) write-outcome surfacing universal; OpcUaClient reconnect machine strongest | **STILL HOLDS** — S7 write path now `S7Driver.cs:981-1083` (per-item statuses preserved through the reconnect wrapper); all other cites unchanged. |
|
||||
| PERF-1 | Five of seven drivers read one tag per wire round-trip | **STILL OPEN** — no batching work landed. |
|
||||
| PERF-2 | AbCip whole-UDT read exists in two never-joined halves | **STILL OPEN** — planner/template-cache unchanged. |
|
||||
| PERF-3 | S7 forked poll loop diffs arrays by reference → publishes every tick | **STILL OPEN** — now `S7Driver.cs:1469` (`Equals(lastSeen?.Value, current.Value)`). |
|
||||
| PERF-4 | OpcUaClient serializes all traffic on one global gate | **STILL OPEN** — `OpcUaClientDriver.cs` untouched. |
|
||||
| PERF-5 | OpcUaClient discovery enrichment un-chunked Read + silent degrade | **STILL OPEN**. |
|
||||
| PERF-6 | Assorted per-tick allocation churn (TwinCAT re-parse, etc.) | **STILL OPEN** — TwinCAT symbol re-parse now `TwinCATDriver.cs:220, 284, 468`. |
|
||||
| PERF-7 | Deadband only in Modbus | **STILL OPEN**. |
|
||||
| CONV-1 | S7 forked the poll loop; fork diverged both directions | **STILL OPEN** — fork now `S7Driver.cs:1373-1412` (backoff `:1447`, cap `:1363`); neither direction merged. The fork's bare OCE-return is now load-bearing for the new STAB-14. |
|
||||
| CONV-2 | Three config-parse strictness tiers; silent enum defaulting in all six equipment-tag parsers | **STILL OPEN** — all six `ReadEnum` copies + Modbus probe shape unchanged. |
|
||||
| CONV-3 | Four factories take no `ILoggerFactory` → NullLogger in production | **STILL OPEN** — verified `S7DriverFactoryExtensions.cs:19` still `Register(DriverFactoryRegistry)` only; ditto TwinCAT/AbCip/FOCAS. Now more costly: the new S7 reconnect and TwinCAT replay log recovery *only* via `_logger` — invisible in production for S7/TwinCAT until this lands. |
|
||||
| CONV-4 | `ResolveHost` mis-keys equipment-tag refs in every multi-device driver | **STILL OPEN** — TwinCAT's now `TwinCATDriver.cs:689-696`; others unchanged. |
|
||||
| CONV-5 | Health-field publication idioms drift (plain fields in S7/AbCip/TwinCAT/OpcUaClient) | **STILL OPEN** — S7's new `_plcDead`/`_initialized` are gate-guarded (fine), but `_health` remains a plain field written from data paths, poll loops and the probe. |
|
||||
| CONV-6 | OpcUaClient.Contracts drags the OPC UA SDK | **STILL OPEN** (previously-deferred NamespaceMap move). |
|
||||
| CONV-7 | CLI harness divergences | **STILL OPEN** — only change is the uniform `NoWarn OTOPCUA0001` (wire-level projects, commented; consistent with the analyzer triage — neutral). |
|
||||
| CONV-8 | Dead config knobs + stale scaffold docs; no options `Validate()` | **STILL OPEN**. |
|
||||
| UNDER-1 | FOCAS advertises writes it cannot perform | **STILL OPEN**. |
|
||||
| UNDER-2 | OpcUaClient `UnsMappingTable` mandatory-but-unconsumed | **STILL OPEN**. |
|
||||
| UNDER-3 | OpcUaClient alarm filter compares mismatched NodeId encodings | **STILL OPEN**. |
|
||||
| UNDER-4 | S7 authored-array/write/UInt32 gaps | **STILL OPEN** — refs shifted: array-write guard `S7Driver.cs:~1088-1110`, wide-type arrays out of scope `:344, :357`, `UInt32 → DriverDataType.Int32` lossy map `:1315`; `S7TagDto` still has no `ArrayCount`. |
|
||||
| UNDER-5 | AbCip alarm-ack outcomes discarded | **STILL OPEN** — `AbCipAlarmProjection.cs:149-151` (fire-and-forget now sits inside an intentional-self-call pragma — the *dispatch* annotation is correct, the discarded per-item outcomes are still the finding). |
|
||||
| UNDER-6 | Equipment-tag parsers lag the authored-tag feature set | **STILL OPEN**. |
|
||||
| UNDER-7 | Live-risk assumptions documented but ungated | **STILL OPEN** — note the TwinCAT replay path is itself unit-only (no TC3 fixture), joining this list's "needs bench time" set. |
|
||||
| UNDER-8 | Test coverage codec-heavy, failure-path-light | **PARTIALLY FIXED** — S7 reconnect gap closed (`S7DriverReconnectTests` ×4 incl. fatal-vs-data-error classification + reopen-fails-then-recovers; live `S7_1500ReconnectTests` double-gated on sim + bounce cmd); TwinCAT gained `TwinCATReconnectReplayTests` ×4 (replay, multi-tag, unsub-after-reconnect, probe-recycle — fake client). Still absent: AbLegacy reconnect tests, S7/AbCip concurrency tests, connect-*timeout* (vs connect-refused) outage coverage — which is exactly where the new STAB-14 lives. |
|
||||
| UNDER-9 | OpcUaClient 2,154-line monolith; PKCS#12-only identity | **STILL OPEN**. |
|
||||
|
||||
---
|
||||
|
||||
@@ -66,6 +118,15 @@ Every protocol driver is expected to follow the same template:
|
||||
and `DisposeAsync`: cancel loops, await them, dispose CTSs, clear
|
||||
caches, dispose the transport.
|
||||
|
||||
Since the prior review the template gained a ninth expectation, enforced
|
||||
tree-wide by the OTOPCUA0001 analyzer: **driver capability calls are wrapped
|
||||
by `CapabilityInvoker` at the dispatch layer**, and a driver's *internal*
|
||||
self-calls (its own poll loop, its own alarm projection reading through its
|
||||
own `ReadAsync`) carry an explanatory `#pragma` marking them intentional
|
||||
(`S7Driver.cs:1460-1462`, `AbCipAlarmProjection.cs:149-151, 230-232`). This
|
||||
matters here because dispatch-level Polly *timeouts* can now cancel driver
|
||||
I/O mid-PDU — see STAB-15.
|
||||
|
||||
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
|
||||
@@ -76,12 +137,12 @@ and `WriteOnChangeOnly` suppression with read-side invalidation.
|
||||
|
||||
| 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 |
|
||||
| LOC (driver proj) | 2,374 | ~2,170 | 3,762 | 1,830 | ~2,460 | 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) |
|
||||
| Reconnect story | transport auto-reconnect + geometric backoff | **NEW: lazy reconnect via `IS7Plc` seam + probe backstop** (but see STAB-14/15) | evict + recreate handle, no backoff | **no eviction on data path** | **NEW: lazy reconnect + native-sub intent replay + probe-failure recycle** | 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 |
|
||||
@@ -101,38 +162,92 @@ field scenario; **High** = correctness or stability defect likely to bite;
|
||||
**Medium** = architectural debt / divergence with concrete failure modes;
|
||||
**Low** = hygiene.
|
||||
|
||||
STAB-1 and STAB-2 are fixed (see the status table); their residual defects
|
||||
are the new STAB-14..17 below.
|
||||
|
||||
### 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-14 — High — NEW — S7's reconnect wrapper rethrows connect-timeout
|
||||
cancellation, and the poll loop's bare OCE catch then permanently kills the
|
||||
subscription.** `EnsureConnectedAsync` bounds the reopen with a linked
|
||||
`CancelAfter(_options.Timeout)` CTS (`S7Driver.cs:1206-1208`). When the PLC
|
||||
is unreachable-but-not-refusing — pulled cable, powered-off device on a
|
||||
switched network, firewall drop: the *classic* field outage Critical 3
|
||||
targeted — `OpenAsync` throws `TaskCanceledException`. `ReadAsync`'s
|
||||
ensure-wrapper catches `OperationCanceledException` and rethrows
|
||||
**unconditionally** (`:488`, no `when (cancellationToken.IsCancellationRequested)`
|
||||
filter; `WriteAsync` same at `:1000`), so the timeout escapes as OCE to the
|
||||
caller. In the poll loop that caller is `PollLoopAsync`, whose
|
||||
`catch (OperationCanceledException) { return; }` (`:1383, :1404`) treats
|
||||
*any* OCE as teardown and exits — silently: no log, no health change, no
|
||||
`HandlePollFailure`. Net: after a connect-timeout outage, S7 reads and
|
||||
writes self-heal (the very fix), but **every S7 subscription poll loop that
|
||||
ticked during the outage is permanently dead until redeploy** — data-loss in
|
||||
the exact scenario class Critical 3 was meant to close. The live bounce test
|
||||
(`S7_1500ReconnectTests`) cannot catch this: `docker restart` produces
|
||||
connection-*refused* (`SocketException` → the degrade-to-Bad path the unit
|
||||
tests cover), never connect-*timeout*. *Recommendation:* filter the
|
||||
wrapper's OCE rethrow on the caller's token (timeout-OCE joins the
|
||||
degrade-to-`BadCommunicationError` path), and filter the poll loop's OCE
|
||||
catches on `ct.IsCancellationRequested`; add a fake-factory unit test where
|
||||
`OpenAsync` honours a token that only the `CancelAfter` fires.
|
||||
|
||||
**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-15 — High — NEW — S7's connection-fatal classification misses
|
||||
framing/desync errors and cancellation-during-I/O, reproducing Modbus's
|
||||
STAB-3 desync; and the probe never marks the handle dead.**
|
||||
`IsS7ConnectionFatal` (`S7Driver.cs:1239-1251`) treats only
|
||||
`SocketException`/`IOException`/`ObjectDisposedException` (walking inners)
|
||||
and `PlcException{ErrorCode.ConnectionError}` as fatal. Two gaps:
|
||||
(a) a caller-token cancellation mid-PDU — server request deadline, or a
|
||||
dispatch-layer Polly timeout now that `CapabilityInvoker` wraps driver calls
|
||||
— abandons a half-read ISO-on-TCP response on the socket; the connection is
|
||||
kept (OCE is not classified), and the next call reads the stale bytes.
|
||||
S7.Net then surfaces framing faults (`TPKTInvalidException`,
|
||||
`PlcException` `WrongNumberReceivedBytes`, `InvalidDataException`) — none of
|
||||
which are classified fatal either — so the single gated connection stays
|
||||
**permanently desynchronized**, returning `BadDeviceFailure`/
|
||||
`BadCommunicationError` forever on a socket that a reopen would fix. This is
|
||||
the exact Modbus STAB-3 failure mode rebuilt in the new S7 path.
|
||||
(b) The probe loop swallows every failure (`:1542`) without calling
|
||||
`MarkConnectionDeadIfFatal`, so its backstop role only works when
|
||||
`IsConnected` flips false on its own; a wire-dead or desynced handle whose
|
||||
`TcpClient.Connected` stays true is re-probed and re-fails forever
|
||||
(contrast TwinCAT, whose probe now force-recycles on failure —
|
||||
`TwinCATDriver.cs:631-643`). *Recommendation:* classify framing exceptions
|
||||
and cancellation-observed-during-I/O as connection-fatal (tear down before
|
||||
propagating, mirroring the STAB-3 fix recommendation), and mark-dead from
|
||||
the probe's catch.
|
||||
|
||||
**STAB-16 — Medium — NEW — TwinCAT replay failure is silent to subscribers
|
||||
and never retried while the client stays up.**
|
||||
`ReplayNativeRegistrationsAsync` logs and skips a failed intent
|
||||
(`TwinCATDriver.cs:801-816`) — deliberately, so one bad symbol can't abort
|
||||
recovery of the rest (good) — but the failed registration keeps its dead old
|
||||
handle (`SwapHandle` runs only on success), the `OnChange` callback shape
|
||||
(`Action<string, object?>`) has no quality channel so nothing can push a Bad
|
||||
snapshot, and nothing schedules a retry: replay runs only when a *new client*
|
||||
is installed. If the fresh client is healthy and one `AddNotificationAsync`
|
||||
failed transiently (ADS notification-quota blip, symbol mid-redeploy), that
|
||||
tag's push feed is dead with only a warning log — the same
|
||||
silent-subscription failure STAB-2 was about, now scoped to single tags.
|
||||
*Recommendation:* on replay failure degrade driver health and emit a
|
||||
Bad-quality `OnDataChange` for the affected reference (the driver holds
|
||||
handle + reference even though the closure doesn't), and re-attempt failed
|
||||
intents on the next probe tick rather than the next reconnect.
|
||||
|
||||
**STAB-17 — Low — NEW — Unsubscribe racing a replay leaks a live ADS
|
||||
notification.** `NativeRegistration.Dispose` (`TwinCATDriver.cs:608-613`)
|
||||
runs gate-free: it removes the intent from the device registry and disposes
|
||||
the current handle. A concurrent reconnect-replay has already snapshotted
|
||||
the intent array (`:786`) and may complete `AddNotificationAsync` *after*
|
||||
the dispose; `SwapHandle` then installs the fresh handle into a registration
|
||||
nobody owns — the ADS notification stays registered on the new client for
|
||||
its lifetime, firing `OnDataChange` for an unsubscribed handle. Narrow
|
||||
window (unsubscribe must race the replay of a reconnect), bounded blast
|
||||
radius (one orphan notification per race until the next client swap).
|
||||
*Recommendation:* after `SwapHandle` in the replay, re-check
|
||||
`device.NativeRegistrations.ContainsKey(reg.Id)` and dispose the new handle
|
||||
if absent (or take `ConnectGate` in `Dispose`).
|
||||
|
||||
**STAB-3 — High — Modbus transport desynchronizes after a response
|
||||
timeout.** `SendAsync` reconnect-retries only on `IsSocketLevelFailure`
|
||||
@@ -146,7 +261,8 @@ 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.
|
||||
before propagating. Fix this together with STAB-15 — it is the same defect
|
||||
class in two drivers now.
|
||||
|
||||
**STAB-4 — High — AbCip runs concurrent operations on shared libplctag
|
||||
handles with no serialization.** `ReadSingleAsync` / `ReadGroupAsync` /
|
||||
@@ -154,10 +270,10 @@ handles with no serialization.** `ReadSingleAsync` / `ReadGroupAsync` /
|
||||
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.
|
||||
(`AbCipAlarmProjection.cs:230-232`) 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`);
|
||||
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.
|
||||
@@ -183,12 +299,14 @@ immediately disposes the CTS and runtimes without awaiting loop exit
|
||||
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`),
|
||||
the CTS without draining `PollTask` (`Driver.S7/S7Driver.cs:1350-1354`),
|
||||
so `Task.Delay` on the disposed source can throw `ObjectDisposedException`
|
||||
that the loop only catches as OCE (`S7Driver.cs:1233-1234`) —
|
||||
that the loop only catches as OCE (`S7Driver.cs:1396-1397`) —
|
||||
`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.
|
||||
(`PollGroupEngine.cs:99-112, 136`). (S7's `ShutdownAsync` does drain
|
||||
`PollTask` bounded — the gap is specifically per-subscription unsubscribe.)
|
||||
*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
|
||||
@@ -196,12 +314,12 @@ and AbLegacy.** AbLegacy's `_options` is readonly ctor state and
|
||||
(`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
|
||||
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
|
||||
"empty the driver" redeploy is ignored. *Recommendation:* make config
|
||||
re-parse on initialize a template requirement; add a consuming test per
|
||||
driver.
|
||||
|
||||
@@ -210,12 +328,17 @@ 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`).
|
||||
TwinCAT — a connect attempt per call (`TwinCATDriver.cs:706-721`); **S7
|
||||
(new) — a full TCP connect attempt bounded only by `_options.Timeout` on
|
||||
every read/write batch and every probe tick while the PLC is down**
|
||||
(`S7Driver.cs:1188-1224` retries unconditionally on each call; the poll
|
||||
loop's capped backoff `:1393-1397` shields subscriptions only).
|
||||
`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.
|
||||
S7's bespoke loop is the only subscriber-side backoff in the fleet.
|
||||
*Recommendation:* add optional failure backoff to `PollGroupEngine` (adopt
|
||||
S7's `ComputeBackoffDelay`, `S7Driver.cs:1447`) and a small connect-attempt
|
||||
throttle to the lazy-reconnect drivers (S7's `EnsureConnectedAsync` is now
|
||||
the natural home for a last-attempt timestamp).
|
||||
|
||||
**STAB-9 — Medium — `PollGroupEngine`'s `onError` sink is dead code: no
|
||||
driver passes it.** All five consumers construct the engine without the
|
||||
@@ -223,7 +346,7 @@ 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
|
||||
"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.
|
||||
@@ -254,23 +377,31 @@ flagged as an unverified assumption that will likely fail
|
||||
(`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`).
|
||||
connect itself (`AdsTwinCATClient.cs:73-80`). **New compounding:** the
|
||||
probe loop's `RecycleClientAsync` waits on `ConnectGate` with
|
||||
`CancellationToken.None` (`TwinCATDriver.cs:835`), so a connect wedged under
|
||||
the gate now also wedges the probe loop beyond cancellation.
|
||||
|
||||
**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.
|
||||
`ModbusDriver.cs:897-942`; S7 `S7Driver.cs:981-1083` including the
|
||||
PUT/GET-denied → `Faulted` escalation, preserved through the reconnect
|
||||
rework; 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`) remains the strongest
|
||||
reconnect implementation in the subsystem. The two new reconnect paths are
|
||||
also well-shaped where it counts: S7's dead-handle flag guarantees
|
||||
exactly-once disposal under the gate (`S7Driver.cs:1188-1204`), and
|
||||
TwinCAT's register/replay share one `ConnectGate` with
|
||||
intent-stored-only-after-handle-live ordering (`TwinCATDriver.cs:508-520`)
|
||||
— the obvious double-registration race was designed out.
|
||||
|
||||
### 2. Performance
|
||||
|
||||
@@ -280,7 +411,7 @@ Only Modbus (block coalescing + auto-prohibition/bisection,
|
||||
`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
|
||||
(`S7Driver.cs:466-545`); 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`).
|
||||
@@ -298,7 +429,7 @@ reads, AbLegacy spans.
|
||||
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
|
||||
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
|
||||
@@ -310,7 +441,7 @@ 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
|
||||
(`S7Driver.cs:1469`); 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
|
||||
@@ -352,14 +483,17 @@ in or beside `PollGroupEngine`.
|
||||
|
||||
**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`'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:*
|
||||
(`S7Driver.cs:1373-1412`). The fork is *better* in two ways the engine
|
||||
never absorbed (capped exponential failure backoff `:1393-1397, 1447`;
|
||||
poll failures logged + degrade health `:1421-1437`) and *worse* in three
|
||||
ways the engine already fixed or avoided (reference-equality array diff —
|
||||
PERF-3; CTS disposed without draining the loop — STAB-6; **and the bare
|
||||
`catch (OperationCanceledException) { return; }` that STAB-14 turns into
|
||||
permanent subscription death**). This is the textbook cost of template
|
||||
divergence: each side owns fixes the other needs, and the fork's OCE
|
||||
handling is now load-bearing for a High-severity defect. *Recommendation:*
|
||||
extend `PollGroupEngine` with backoff + onError-driven health, then move
|
||||
S7 onto it and delete the fork.
|
||||
|
||||
@@ -398,9 +532,12 @@ logging to `NullLogger` in production.** S7, TwinCAT, AbCip and FOCAS
|
||||
`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/
|
||||
(`Host/Drivers/DriverFactoryBootstrap.cs:100-107`). This finding got
|
||||
*more* expensive since the prior review: the S7 reconnect path
|
||||
("S7Driver reconnected", replay tallies) and the TwinCAT replay path
|
||||
("re-registered {Replayed}/{Total} native notifications") log recovery and
|
||||
partial-replay failures **only** through `_logger` — in production those
|
||||
two brand-new recovery mechanisms run blind. Modbus/AbLegacy/OpcUaClient/
|
||||
Galaxy have the logger-aware overload. *Recommendation:* one-line fix per
|
||||
factory; align all eight `Register` signatures.
|
||||
|
||||
@@ -409,13 +546,14 @@ 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
|
||||
`TwinCATDriver.cs:689-696`, `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.
|
||||
and the CapabilityInvoker dispatch wiring (task #10) now actually consumes
|
||||
these keys in production, the mis-keying is no longer latent.
|
||||
*Recommendation:* route `ResolveHost` through
|
||||
`EquipmentTagRefResolver` and derive the host from the parsed def
|
||||
(`deviceHostAddress` / `unitId`).
|
||||
@@ -424,14 +562,14 @@ the per-call host resolver is effectively inert for the flagship path.
|
||||
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
|
||||
(`S7Driver.cs:240`), 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
|
||||
probe lock (`~TwinCATDriver.cs:554`); 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
|
||||
@@ -440,7 +578,7 @@ on successful writes (`AbLegacyDriver.cs:344-346` vs
|
||||
**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
|
||||
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
|
||||
@@ -462,19 +600,21 @@ 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()`
|
||||
endpoint). The six csprojs gained a commented `NoWarn OTOPCUA0001` in the
|
||||
analyzer wiring — consistent and correct for wire-level harnesses.
|
||||
*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”
|
||||
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`).
|
||||
`S7Driver.cs:340-420`).
|
||||
|
||||
### 4. Underdeveloped areas
|
||||
|
||||
@@ -512,15 +652,17 @@ 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`).
|
||||
(`S7Driver.cs:~1088-1110`), wide-type arrays are a stated follow-up
|
||||
(`:344, :357`), and `UInt32` surfaces lossily as `Int32`
|
||||
(`S7Driver.cs:1315`).
|
||||
|
||||
**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`
|
||||
(`AbCipAlarmProjection.cs:149-151`); 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.
|
||||
(The new OTOPCUA0001 pragma at this site correctly annotates the
|
||||
*self-call* as intentional; the discarded outcomes remain the defect.)
|
||||
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
|
||||
@@ -544,30 +686,32 @@ 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
|
||||
(STAB-11), **and now the entire native-notification replay path, which is
|
||||
fake-client-verified only** (no TC3 docker fixture exists; STATUS.md
|
||||
acknowledges the unit tests are authoritative). 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.
|
||||
uneven — partially improved.** The prior review's worst gap — **zero S7
|
||||
reconnect tests** — is closed: `S7DriverReconnectTests` (4 tests: fatal
|
||||
fault → reopen, data-address error → no reopen, raw socket exception →
|
||||
reopen, reopen-fails-then-recovers) plus the env-gated live
|
||||
`S7_1500ReconnectTests` (a real `docker restart` bounce, live-verified per
|
||||
STATUS.md). TwinCAT gained `TwinCATReconnectReplayTests` (4 tests: replay
|
||||
onto fresh client, all-tags replay, unsubscribe-after-reconnect disposes +
|
||||
stops replaying, probe-failure recycle) — fake-client only, per UNDER-7.
|
||||
Remaining systemic gaps: **zero reconnect tests for AbLegacy** (STAB-10
|
||||
lives there), zero concurrency tests for S7 and AbCip (STAB-4 lives
|
||||
there; only AbLegacy has a dedicated runtime-concurrency suite), **no
|
||||
connect-timeout (vs connect-refused) outage test anywhere** — precisely
|
||||
the STAB-14 blind spot — and OpcUaClient still has the widest feature
|
||||
surface on the lowest test density. `Cli.Common`'s `DriverCommandBase` has
|
||||
no direct tests. There remains **zero TODO/HACK/FIXME/
|
||||
NotImplementedException debt** in the subsystem.
|
||||
|
||||
**UNDER-9 — Low — OpcUaClient is a 2,154-line monolith.** Six separable
|
||||
responsibilities share one file (config/PKI/identity, failover sweep,
|
||||
@@ -584,33 +728,43 @@ loads PKCS#12 while the options doc promises PEM support
|
||||
|
||||
## 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. |
|
||||
| Dimension | Rating (1-5) | Prior | Justification |
|
||||
|---|---|---|---|
|
||||
| Stability | **3** | 3 | Both Criticals are genuinely closed — a routine PLC power-cycle no longer permanently kills S7, and TwinCAT native subs now replay — and the fixes are well-shaped (seam-based, gate-disciplined, guard-tested). Held at 3 rather than promoted because the S7 fix ships a High regression in the same scenario class (STAB-14: connect-*timeout* outages permanently kill S7 subscription polls) and reproduces the Modbus desync defect (STAB-15), while the untouched silent-corruption Highs (STAB-3/4/5) and the shutdown races (STAB-6) all remain. |
|
||||
| Performance | **2** | 2 | Unchanged — no batching work landed. Only 2 of 7 drivers batch reads; AbCip's flagship UDT batching is still half-built; deadband exists only in Modbus; the subsystem scales by tag count, not request count. S7's new per-call reconnect attempt marginally worsens dead-device cost (STAB-8). |
|
||||
| Conventions | **3** | 3 | Unchanged in substance. The shared seams are real and the analyzer/pragma discipline is a genuine improvement, but the S7 poll-loop fork (now hosting a High defect in its OCE handling), three-tier config-parse strictness, six-way `ReadEnum` copy-paste, logger-registration asymmetry (now hiding the new recovery logs in production), and drifting health idioms all persist. |
|
||||
| Underdeveloped areas | **3** | 3 | Slightly improved within the band: the S7 reconnect test gap is closed with both unit and live coverage, TwinCAT replay has fake-client guards. Advertised-but-dead features (FOCAS writes, `UnsMappingTable`, `DisableFC23`, `ConnectionSize`), equipment-tag parser lag, AbLegacy/concurrency test gaps, and the growing unverified-against-hardware set (now including the replay path) keep it at 3. |
|
||||
|
||||
---
|
||||
|
||||
## 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).
|
||||
1. **STAB-14 — S7 connect-timeout OCE kills subscription polls** — a
|
||||
two-line filter class fix (`when (cancellationToken.IsCancellationRequested)`
|
||||
on the ensure-wrapper rethrow + the poll-loop catches) plus a
|
||||
fake-factory connect-timeout unit test. This is a regression inside the
|
||||
Critical-3 fix and belongs at the top.
|
||||
2. **STAB-15 + STAB-3 together — timeout/framing ⇒ connection-fatal** in
|
||||
both the new S7 classification and the Modbus transport; same defect
|
||||
class, same fix shape (tear down before propagating). Add probe-side
|
||||
`MarkConnectionDeadIfFatal` in S7.
|
||||
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).
|
||||
4. **TwinCAT replay hardening** (STAB-16 Bad-quality surfacing + probe-tick
|
||||
retry; STAB-17 ownership re-check after `SwapHandle`).
|
||||
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
|
||||
`onError` in all five consumers, migrate S7 off its fork — this also
|
||||
deletes the fork's dangerous OCE handling for good
|
||||
(CONV-1, STAB-9, PERF-3, STAB-6, STAB-8).
|
||||
6. **Factory `ILoggerFactory`** (CONV-3) — one line per factory, and now a
|
||||
prerequisite for the new reconnect/replay paths being observable in
|
||||
production at all.
|
||||
7. **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
|
||||
8. **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
|
||||
9. Sweep the one-liners: `ResolveHost` via resolver (CONV-4, now live via
|
||||
CapabilityInvoker dispatch), FOCAS writable-false (UNDER-1), delete
|
||||
`UnsMappingTable` gate (UNDER-2), OpcUaClient alarm-filter encoding
|
||||
(UNDER-3).
|
||||
(UNDER-3), AbLegacy reconnect-test template (UNDER-8).
|
||||
|
||||
Reference in New Issue
Block a user