Files
lmxopcua/docs/reviews/2026-10-21/07-drivers-plc.md
T

50 KiB
Raw Blame History

Review 07 — PLC drivers (AbCip, AbLegacy, Ab.Transport, Ab.LibplctagShared, Modbus, S7, TwinCAT, FOCAS, Calculation, Cli)

Repo: /Users/dohertj2/Desktop/OtOpcUa @ d51d35bd (master). Production code only. Every finding below was verified by reading the cited lines; sub-reviewer candidates that could not be confirmed were dropped. Paths are relative to src/Drivers/ unless absolute.

Legend: Sev Critical/High/Medium/Low · Conf High/Medium.


1. Memory / resource leaks

L1. Calculation: a script that overruns RunTimeout leaks one thread-pool thread per trigger, unbounded

Sev High · Conf High — ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationEvaluator.cs:186-187, src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/TimedScriptEvaluator.cs:78-81, ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationTimerScheduler.cs:66-80

var timed = new TimedScriptEvaluator<VirtualTagContext, object?>(evaluator, _runTimeout);
var raw = timed.RunAsync(context).GetAwaiter().GetResult();          // no token passed
// TimedScriptEvaluator: var runTask = Task.Run(() => _inner.RunAsync(context, ct), ct); await runTask.WaitAsync(Timeout, ct)

The timeout abandons the Task.Run thread (nothing can interrupt a synchronous Roslyn delegate). The timer group's InFlight flag is cleared in finally once _evaluate returns — i.e. as soon as the 2 s timeout fires — so a CPU-bound script (while(true), a large array loop) on a 1 s timer leaks one pool thread per tick, forever; change-triggered tags do the same per input change. Health stays Healthy (CalculationDriver.cs:341), so nothing alerts until the pool is exhausted and unrelated work (gRPC, other drivers' Task.Run) stalls. Fix: track the in-flight runner per tag and refuse to start another while it runs (publish BadInternalError "previous evaluation still running" + a counter); pass a CancellationToken so awaiting scripts can be cancelled; longer-term, out-of-process runner.

L2. Calculation: ShutdownAsync never clears compiled scripts — collectible ALCs of every previous generation survive each respawn

Sev Medium · Conf High — CalculationDriver.cs:323-330 (ShutdownAsync), :719+ (Dispose is the only caller of ClearCompiledScripts besides AdoptConfig :257), src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs:1550 (PostStop calls only ShutdownAsync) Nothing in the runtime disposes an IDisposable driver; every config change is a respawn (#665), so each edit leaves the old generation's ALCs + delegates alive until GC finalises the driver graph — the "accreting ALCs" the IScriptCacheOwner contract exists to prevent, just deferred to GC timing. The same applies to the sync Dispose() on AbCip/Modbus/FOCAS (AbCipDriver.cs:1900, ModbusDriver.cs:1761, FocasDriver.cs:1873) — dead code in production. Fix: call _evaluator.ClearCompiledScripts() from ShutdownAsync (idempotent), or dispose IDisposable drivers from PostStop.

L3. Modbus: ConnectAsync overwrites a failed TcpClient without disposing it; the reconnect backoff loop runs inside the transport gate

Sev Medium · Conf High — ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusSocketLifecycle.cs:84-92, 102-131, ModbusTcpTransport.cs:68-101

_client = new TcpClient(target.AddressFamily);      // previous failed _client dropped, never Dispose()d
...
if (delay > TimeSpan.Zero) await Task.Delay(delay, ct);   // inside SendAsync's _gate; up to 10 attempts, 30 s cap

A connection-refused/reset device makes one poll's SendAsync hold _gate for ≈ 81 s (0 + 0.2 + 0.4 … 30 s × 10), leaking up to 9 TcpClients per loop (finalizer-reclaimed only). Every other read, every WriteAsync (the actor's 5 s write CTS cancels the gate wait → BadInternalError) and the probe loop (2 s CTS → host "Stopped") queue behind the one dead socket. Dns.GetHostAddressesAsync (:79) is outside the per-op CancelAfter. Fix: _client?.Dispose() before reassignment; cap the in-gate reconnect to one attempt and leave multi-attempt backoff to the driver/watchdog; put DNS under the linked timeout CTS.

L4. AbCip native: a deterministic controller refusal other than Unsupported/TypeMismatch re-creates the tag runtime on every poll (lease acquire/release churn; Forward Open/Close churn when it is the device's only tag)

Sev Medium · Conf High — ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs:1009-1021 (generic catch → EvictRuntime + Degraded), :1475-1488 (cold create catch), AbCipDriverProbe.cs:189 (IsReachableStatus => Ok or NotFound), NativeAbCipTagRuntime.cs:394-401 (Dispose → ReleaseInBackground), ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/DeviceSessionPool.cs:106-138 (last lease closes the connection) A tag the controller answers NotFound (0x04/0x05 — a renamed tag) never gets cached: each poll allocates a runtime, acquires a lease, reads, releases. If it is the only tag on that DeviceKey, each release is the last lease → Forward Close + UnRegisterSession, and the next poll TCP-connects + RegisterSession + Forward Opens again (throttled to ≥ 1 s by ForwardOpenThrottle). See H1 for the health/backoff side. Fix: cache the runtime with its refusal status (the TypeMismatch branch at :1457-1467 already does exactly this) for NotFound/OutOfBounds/NotAllowed, and treat them as per-tag Bad with no eviction.

L6. FOCAS: handle-recycle loop disposes the device client outside ConnectGate — orphans a fresh connection or kills a poll mid-read

Sev Medium · Conf High (opt-in feature, default off) — ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs:1665 (state.DisposeClient() with no gate), :2024-2028 (Client?.Dispose(); Client = null; non-atomic), :1849-1850 (EnsureConnectedAsync installs under the gate), :1814 (fast path reads Client without it) Interleaving (a): recycle reads the old client, EnsureConnected installs a fresh connected one, recycle nulls it — the fresh client (two TCP sockets) is never disposed. (b): recycle disposes the client a poll is mid-read on → ObjectDisposedException → BadCommunicationError + Degraded for the rest of that poll, every recycle interval. The driver's own comment says the gate exists because unserialised dispose/install "disposed each other's client". Fix: take device.ConnectGate in the recycle loop and dispose/null under it.

L7. AbLegacy: per-key SemaphoreSlim tables grow with every distinct resolved indirect address (bounded)

Sev Low · Conf Medium — ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs:907-917 (resolvedName = $"{def.Name}=>{concreteText}"), :1109. Each index value an indirect N7:[N7:0] tag sweeps through leaves one semaphore in _runtimeLocks and one in _creationLocks until DisposeRuntimes. Ceiling = file size (≤ 256 words × ≤ 1000 files).

L8. TwinCAT lifecycle nits

Sev Low · Conf High — ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs:234 (ShutdownAsync awaits the probe task unbounded while :197/:1445 bound it — a probe wedged in the synchronous AdsClient.Connect, AdsTwinCATClient.cs:80, hangs the actor's teardown-first reconnect); AdsTwinCATClient.cs:36-41 (notification channel drops silently at 50k, no counter); :646-651 (NotificationRegistration.Dispose removes from _notifications but not the queue, so a callback can fire after unsubscribe); in-place InitializeAsync never clears _tagsByRawPath (:147-163) and _nativeSubs keep references to disposed DeviceStates (only reachable via a direct re-init with changed config — the runtime respawns, #665).

L5. Ab.Transport: per-frame allocations on the hot path (low)

Sev Low · Conf High — Ab.Transport/Eip/EncapsulationClient.cs:283-292 allocates head + frame per reply; Wire/CipWriter.cs allocates a 256-byte buffer + ToArray() per request; Logix/LogixTagClient.cs:62-63 builds a List<byte> then ToArray(). Modbus transports likewise allocate PDU/ADU/response arrays per request (ModbusTcpTransport.cs, ModbusRtuOverTcpTransport.cs), and NormalizeWordOrder copies even for BigEndian. At PLC poll rates this is Gen0 noise; only matters at thousands of PDUs/s. Not a defect.


2. Unimplemented / stubbed

U1. Modbus: Family, MelsecSubFamily, DisableFC23 are authored in the AdminUI but nothing consumes them; the whole Driver.Modbus.Addressing project has no production caller

Sev Medium · Conf High — ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverFactoryExtensions.cs:87-93, ModbusDriverOptions.cs:91-97 (DisableFC23 documented no-op), ModbusTagDefinitionFactory.cs:34-95 (reads numeric address + region only); grep finds zero references to ModbusAddressParser/ParsedModbusAddress/DirectLogicAddress/MelsecAddress outside the Addressing assembly. The "Family" dropdown (DL205 octal V-memory, MELSEC hex X/Y) changes nothing at runtime; an operator who picks MELSEC and authors D100 gets BadNodeIdUnknown. The only region/type cross-check (ModbusAddressParser.cs:176-180) is therefore never run (see H5). Fix: route the editor's address field through ModbusAddressParser.Parse(text, Family, SubFamily) and persist the resolved region/offset, or remove the three fields until they do something.

U2. Modbus: block-read coalescing is OFF by default and never covers coils, discrete inputs, BitInRegister or String

Sev Medium · Conf High — ModbusDriver.cs:476-478, :904-913; ModbusDriverOptions.cs:117 (MaxReadGap = 0 = disabled)

var coalesced = _options.MaxReadGap > 0 ? await ReadCoalescedAsync(...) : new HashSet<int>();
if (tag.Region is not (HoldingRegisters or InputRegisters)) continue;   // coils/DI never coalesce
if (tag.DataType is String or BitInRegister) continue;

Default-configured device with 200 holding registers = 200 FC03 round-trips per poll; 16 BitInRegister tags on one status word = 16 reads of the same register; 64 adjacent coils = 64 FC01s. 0 means "disabled" rather than "adjacent only". Fix: treat MaxReadGap >= 0 as enabled with gap 0 = adjacent, default ~8; add FC01/FC02 bit-block coalescing; fold same-register BitInRegister tags into one read.

U3. Modbus: String[] and BitInRegister[] tags read fine and are advertised Operate-writable, but every write fails BadInternalError

Sev Low · Conf High — ModbusDriver.cs:655-673 (decode supports both), :1177-1182, 1295-1301, 1314 (ElementByteCount throws for String; Convert.ToBoolean(bool[]) → InvalidCastException). Fix: refuse at FromTagConfig or return BadNotSupported.

U4. S7: Bool arrays ignore the parsed bit offset

Sev Low · Conf High — ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs:1340-1356, 1643-1649. ReadArrayAsync reads count bytes at ByteOffset; DecodeArrayBlock takes block[i] & 0x01. DB1.DBX0.3 with arrayLength: 8 returns bit 0 of bytes 0..7, silently. Fix: reject non-zero bit offsets on Bool arrays at authoring/init, or decode bit-packed.

U5. Calculation: a change-triggered tag with zero literal ctx.GetTag reads never evaluates and nothing warns

Sev Medium · Conf High — CalculationDriver.cs:178-186 (_changeDependents from DependencyRefs only), :354-359; src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs:395-456 (only Missing/NotFound/Cycle). ctx.GetTag("Plant/" + line + "/Temp") or return ctx.Now.Hour; with the default changeTriggered:true stays BadWaitingForInitialData forever while the driver reports Healthy. Fix: deploy-gate CalculationNoEffectiveTrigger when change-triggered && deps.Count == 0 && no timer; or evaluate once at init.

U6. AbLegacy native: PLC-5 writes are not shipped, PLC-5 reads are N-file only, PLC-5 sub-elements refused (documented, surfaced correctly)

Sev Low · Conf High — Ab.Transport/Pccc/PcccServices.cs:16-19 (Plc5WriteVerified = false), :112-114; ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagRuntime.cs:150, 261-263. All surface as AbStatus.Unsupported → BadNotSupported with no health impact, and Native is opt-in for AbLegacy. Noted as a capability gap, not a defect.

U8. FOCAS: FocasDataType.String is authorable but every read path publishes a number at Good

Sev Medium · Conf High — editor lists every enum member (AdminUI/Components/Shared/Uns/TagEditors/FocasTagConfigEditor.razor:18); ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasConstants.cs:116 (_ => FocasPmcDataType.Byte), FocasPmcCoalescer.cs:135 (_ => (object)raw, a long), Wire/WireFocasClient.cs:392-393 (PARAM default reads Int32), :456-458 (MACRO returns double). A String-typed OPC UA node receives a boxed long/int/double at Good. Fix: reject String in FocasTagDefinitionFactory.FromTagConfig/capability matrix and drop it from the editor.

U9. TwinCAT: array writes unimplemented but advertised writable; string writes bypass the codec

Sev Medium · Conf High — TwinCATDriver.cs:521-524 (def.ArrayLength ignored), AdsTwinCATClient.cs:908-925 (ConvertForWrite has no array case → Convert.ToInt32(object) → InvalidCastException → reported BadTypeMismatch at :532-535 not BadNotSupported), :586-590 (DiscoverAsync advertises arrays Operate); strings go to _client.WriteValueAsync(symbolPath, string) (:472-473) with no length check against STRING(n) and host-ANSI encoding the read side avoids (TwinCATStringCodec.cs:7-10). Fix: implement raw-bytes array/string writes mirroring the codecs, or refuse BadNotSupported and advertise ViewOnly.

U10. AbCip/AbLegacy: array writes — config/UI allow them, both drivers encode a scalar

Sev Medium · Conf Medium — AbCipDriver.cs:1191-1255 (no array branch; WriteWidthRefusal skips arrays :1739), NativeAbCipTagRuntime.cs:319-384 (Convert.ToInt32(value)), :236 (client.WriteAsync(path, type, null, 1, buffer)); LibplctagTagRuntime.cs:228-290; NativeAbLegacyTagRuntime.cs:287-302, 418-449. An int[] → InvalidCastException → BadTypeMismatch and Degraded (:1281-1288); a scalar to an array tag writes element 0 natively, while libplctag SetInt32(0,…) resends the whole last-read buffer (stale elements 1..n-1). Medium confidence only because the server-side array-write gate was not traced end to end. Fix: implement array encode (elements = count) or refuse IsArrayTag(def) writes with BadNotSupported before any runtime exists.

U11. AbCip: BOOL-array element reads decode "low byte of the DWORD ≠ 0", not the addressed bit

Sev Medium · Conf Medium — NativeAbCipTagRuntime.cs:440-442 (IsTypeMismatch exempts authored Bool answered 0xD3), :279 (Bool => bitIndex is int bit ? GetBit(bit) : Span(offset, 1)[0] != 0), LibplctagTagRuntime.cs:121-123 (GetInt8(offset) != 0); AbCipDriver.cs:956-958 (width check skipped for Bool). BoolArr[n] reads Good with "any of bits 0-7 set", subscript ignored; the write side already refuses this shape (WriteWidthRefusal → BadNotSupported). No capture/test covers which DWORD the controller returns. Fix: decode bit n % 32 of element n / 32, or return BadTypeMismatch as the write path does.

U12. TwinCAT native subscribe silently drops unresolvable references

Sev Low · Conf High — TwinCATDriver.cs:669-670 (continue on resolve/device miss) vs the poll path's BadNodeIdUnknown (:282-290). The node keeps whatever status the server seeded. Fix: push a BadNodeIdUnknown snapshot before continue.

U7. CLI gaps (low)

Sev Low · Conf High — Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/Commands/SubscribeCommand.cs:60-66 writes from the poll thread with no lock/try-catch (every other CLI does); AbCip/AbLegacy probe/read/write print a stack trace on Ctrl-C (no OperationCanceledException arm); Driver.Cli.Common/SnapshotFormatter.cs:107-114 renders arrays as System.Int32[].


3. Performance limiters

P1. S7: a read pass keeps issuing wire ops on a handle already marked dead; each pays the full 5 s deadline, and health is written only at the end of the pass

Sev High · Conf High — S7Driver.cs:707-716 (span loop), :741-765 (per-tag loop), :758, 1037, 1156 (_plcDead = true set), :2310-2313 (MarkConnectionDeadIfFatal), :787-800 (single health write in finally) No loop checks _plcDead between items. On a frozen-but-established peer (cable pull, firewall DROP — the case S7OperationDeadline exists for) the first span times out after Timeout and marks the handle dead; the remaining N spans/tags each still go to the wire and each wait another 5 s. A 20-span pass ≈ 100 s under _gate, and Degraded is not visible until the pass ends — so the #527 watchdog's 90 s clock starts only then: detection ≈ pass length + 90 s. The probe loop cannot help (same gate). Fix: once _plcDead flips inside a pass, stamp the remaining items BadCommunicationError without I/O, record the failure, exit the pass.

P2. S7: one bad member fails its entire span every poll — no per-tag fallback (the planner's stated invariant is false)

Sev High · Conf High — S7Driver.cs:1138-1165; S7ReadSpanPlanner.cs:11-16

catch (Exception ex) { var status = RecordReadFailure(ex, outcome);
    foreach (var member in span.Members) results[member.OriginalIndex] = new DataValueSnapshot(null, status, null, now); return; }

A span's block is the union of its members' ranges; if one member runs past the DB's actual length or over-declares stringLength (width = StringLength + 2, :1430), the CPU refuses the whole block and every neighbour within the 220-byte span is BadDeviceFailure + Degraded on every poll. The multi-var path (:1040-1047) and Modbus's coalescer (ModbusDriver.cs:980-984, auto-prohibit + per-tag fallback) both fall back; the span path does not. Fix: on a non-connection-fatal span PlcException, re-read members individually for that pass and remember the span as "do not group".

P3. Modbus: transport never self-heals after a second consecutive socket failure — every later call fails fast until the 90 s watchdog tears down all subscriptions

Sev High · Conf High — ModbusTcpTransport.cs:66, 87-97; ModbusRtuOverTcpTransport.cs:94, 116-127; ModbusSocketLifecycle.cs:147-154

if (_life.Stream is null) throw new InvalidOperationException("Transport not connected");   // :66, BEFORE the gate and the retry arm
catch (Exception ex) when (_life.AutoReconnect && ModbusSocketLifecycle.IsSocketLevelFailure(ex))   // :87, single retry

A per-op timeout tears the socket down; the one retry reconnects and resends; if that also fails, _stream stays null and :66 throws before :87 can ever reconnect. Recovery is exclusively the comms-loss watchdog (Degraded + 90 s frozen) → ShutdownAsync → _poll disposed (all subscriptions) → re-init → re-subscribe. Two lost replies in a row from a slow device cost ≥ 90 s of Bad data plus full subscription churn. S7 reopens lazily on the next call. Fix: when _life.Stream is null && AutoReconnect, attempt a backoff-throttled connect inside the gate instead of throwing.

P4. Calculation: a script with a compile error is re-fed to Roslyn on every trigger, under the driver-wide _evalGate, and floods both logs

Sev High · Conf High — src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/CompiledScriptCache.cs:71-80 (faulted Lazy evicted), CalculationEvaluator.cs:158-161, CalculationDriver.cs:518 (lock (_evalGate) around the run), :596 (PublishScriptLog on every failure) Source never changes between triggers (fixed in the artifact), so each trigger of a broken tag is a full CSharpCompilation.Create + diagnostics serialised against every other calc tag on the driver; one broken tag on a 250 ms input starves the rest. The node publish is correctly once-per-transition, but the host Warning and script-log entry fire every time. Fix: negative-cache compile failures by source hash (ClearCompiledScripts already drops it on redeploy); gate the Warning/script-log on the Good→Bad transition.

P5. Calculation: sync-over-async on a pool thread with a nested Task.Run manufactures false timeouts under load

Sev Medium · Conf High — CalculationDriver.cs:494 (pump is Task.Run), CalculationTimerScheduler.cs:53 (Timer callbacks are pool threads), CalculationEvaluator.cs:187. The WaitAsync clock starts before the inner item is scheduled, so pool starvation (which L1 causes) is charged to the script: "script timed out after 2.0s" → BadInternalError for a script that never ran. Fix: make the pump async and await timed.RunAsync, or run inline with a watchdog.

P7. AbLegacy: reads are strictly serial with no per-device backoff — one dead device stalls every device in the driver and can trip the watchdog against a healthy one

Sev High · Conf High — ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs:375-479 (plain for + await runtime.ReadAsync at :425), :443, 474 (failed read evicts the handle), :1101-1128 (EnsureTagRuntimeAsync re-inits with a full Timeout each), no ConnectionBackoff/ConnectGate (grep: none); NativeAbLegacyTagRuntime.cs:127 (AllowPacking = false) The host subscribes all of a driver's refs in one PollGroupEngine loop (DriverHostActor.cs:3631). With device B down and 50 tags, each poll pays 50 × Timeout (2 s) serially ≈ 100 s; device A's tags are read once per ~100 s. Per-tag health writes (:446/462/477) flip Degraded/Healthy and LastSuccessfulRead only advances on an A success, so a cycle > 90 s trips the comms-loss watchdog against reachable device A, and the re-init restarts the same cycle. Native transport does not help (no MSP packing; adjacent N/F/B words never coalesced although the array path proves the transport can). AbCip has a milder form: cold waiters hold a window slot while queued on ConnectGate (AbCipDriver.cs:797 → 1431), so a dead device's backoff-expiry attempt can occupy all 8 slots for Timeout once per ≤ 30 s. Fix: per-device ConnectionBackoff + single-flight connect gate (copy AbCip), bounded-parallel window across devices, adjacent-element coalescing per device.

P8. AbCip native: MaxConcurrentReads (default 8) caps Multiple Service Packet coalescing at 8 services per round trip

Sev Medium · Conf High — AbCipDriver.cs:779-784, 797 (window slot taken before runtime.ReadAsync → scheduler.SendAsync), AbCipDriverOptions.cs:121 (= 8, documented for libplctag per-handle concurrency); Ab.Transport/Cip/RequestScheduler.cs TakeBatch/PumpAsync pack only what is queued when the pump wakes. A 4002-byte Large Forward Open can carry ~100+ DINT reads per packet (the scheduler budgets request + reply bytes), so a 1000-tag CompactLogix pays ~125 round trips per poll instead of ~10. Fix: on AbCipTransport.Native make the window effectively unbounded (≥ 64) — the scheduler's reply budget already prevents over-packing.

P9. FOCAS: one slow CNC stalls every device in the subscription; the per-device gate is shared by three loops

Sev Medium · Conf High — FocasDriver.cs:565-651 (sequential walk of the whole batch), SynchronizedFocasClient.cs:143-156 (gate shared with the fixed-tree loop — 9-block cnc_rddynamic2 per axis every 250 ms — and the alarm loop). A dead device is cheap (backoff → ConnectionThrottledException), but a slow device (TCP-accept-then-stall, the live-observed 31i-B case) costs Timeout (2 s) per tag per poll during which no other device is read. Per-poll nit: TryReadFixedTree builds HostAddress + "/" for every device for every reference (:1515), run twice per poll (:681, :577). Fix: bucket by device and read devices concurrently (Task.WhenAll, each serialised by its own gate); precompute the prefix on DeviceState.

P10. TwinCAT: a Warning per bad tag per poll with no once-per-tag dedup

Sev Medium · Conf High — TwinCATDriver.cs:353-362 (CompleteRead logs LogWarning for every non-Good status every poll). 50 typo'd symbols on a 500 ms poll = 100 warnings/s indefinitely (AbCip and FOCAS warn once). Fix: a "warned" set keyed by reference, cleared on Good or re-init.

P11. AbCip: UDT read planner recomputes member layout per member per poll

Sev Low · Conf High — AbCipUdtReadPlanner.cs:51-83 (AbCipUdtMemberLayout.TryBuild(parent.Members) at :70 for every member reference every poll — O(members²) per parent per tick, allocating a dictionary each time); AbCipDriver.cs:780-784 allocates a SemaphoreSlim, Task[], ReadOutcome[] + closures per poll. Only material with EnableDeclarationOnlyUdtGrouping on and large groups. Fix: cache the layout per parent definition.

P6. Modbus: region/type compatibility is not enforced — with coalescing ON, one HoldingRegisters+Bool tag kills the whole subscription's poll cycle every tick

Sev Medium · Conf High — ModbusDriver.cs:931 (RegisterCount(tag) outside any try), :1429-1436 (throw new InvalidOperationException($"Non-register data type …")), ModbusTagDefinitionFactory.cs:52-65 (no cross-check). RegisterCount throws out of ReadCoalescedAsync → out of the reader → PollGroupEngine returns no snapshots for any tag and backs off to 30 s, forever. With coalescing off the same tag reads BadCommunicationError + Degraded. Conversely Coils + Int16 silently returns a bool into an Int32 node. Fix: reject region/type mismatches in FromTagConfig; wrap the per-tag planner math so a bad tag goes to the per-tag path.


4. Other hazards

H1. AbCip: deterministic controller refusals (NotFound / OutOfBounds / NotAllowed / unmapped status) latch Degraded, and the non-NotFound ones open the device-wide 1→30 s backoff window that fails every sibling cold create

Sev Medium · Conf High — AbCipDriver.cs:1009-1021 (generic catch → ReadOutcome.Degraded), :1442-1444 (ConnectionThrottledException for siblings), :1475-1488 (RecordFailure unless IsReachableStatus), AbCipDriverProbe.cs:189 (Ok or NotFound only), :1937 (new ConnectionBackoff(1 s, 30 s)); Ab.Transport/AbStatusCodes.cs:9-23 (0x1C/0x20/0xFF-0x2104… → BadReply → BadCommunicationError) drivers.md says only Unsupported/TypeMismatch are "refusals, not comms faults". A tag authored Arr[999] (0xFF/0x2105 → OutOfBounds) is answered by a working session but records a Forward-Open failure, so any sibling needing a cold create (new subscription, or evicted after a transient) fails fast for up to 30 s with BadCommunicationError. Health latches Degraded while the tag exists; if it is the device's only tag, LastSuccessfulRead freezes and the #527 watchdog re-inits the driver every 90 s against a reachable controller. Fix: widen IsReachableStatus to every status that proves the controller answered (NotFound, OutOfBounds, NotAllowed, TooLarge/TooSmall, TypeMismatch, Unsupported); treat them as per-tag Bad with no health change (as the TypeMismatch branch does); map 0x1C/0x20/0xFF-0x2104 explicitly instead of to BadReply.

H2. Modbus: deadband swallows the Good-after-Bad recovery publish (value-only dedup — the exact #527 trap drivers.md warns about)

Sev High · Conf High — ModbusDriver.cs:236-259

if (snapshot.Value is null) return true;             // Bad passes through, baseline NOT touched
...
if (Math.Abs(newD - oldD) < tag.Deadband.Value) return false;   // status never consulted

Tag publishes 10.0 (baseline 10.0) → comms drop → Bad published, baseline kept → device returns holding 10.0 → engine fires onChange (status differs) → |10−10| < deadband → false: the Good recovery is never published and the node stays Bad until the value drifts past the deadband. Deadband is only authorable via raw TagConfig JSON today (ModbusTagDefinitionFactory.cs:80-82), which narrows exposure, not the defect. Fix: drop the baseline (_lastPublishedByRef.TryRemove) when returning true for a null/Bad snapshot, or baseline on (value, status).

H3. S7: one mis-authored tag faults the whole driver at init and the actor retries the same init every 10 s forever; neither the editor nor the deploy gate catches the throwing cases

Sev High · Conf High — S7Driver.cs:270 (RejectUnsupportedTagConfigs() throws), :313-316 (S7AddressParser.Parse throws FormatException), :336-348 (catch → Faulted → rethrow); S7TagDefinitionFactory.cs:59-64 accepts any non-blank address; DriverInstanceActor.cs:743 retries. The comment at :245-246 says "one bad tag must not fail the whole driver init". A typo'd DB1.DBZ0, DB1.DBW0 typed Float64, or T5 typed Int32 darkens every tag on that PLC. Modbus skips the tag (ModbusDriver.cs:337-345). CSV import / API blobs bypass the editor entirely. Fix: convert init guards + parse pass into per-tag skips (Modbus pattern) and run S7AddressParser.TryParse + guards inside FromTagConfig/Inspect so the deploy gate refuses them.

H4. Modbus and S7: protocol refusals degrade driver health; all-tags-refused produces a 90 s reconnect/resubscribe loop

Sev Medium · Conf High — ModbusDriver.cs:507-511 (ModbusException 01/02/03 → Degraded), :492 vs :510 (health is last-writer-wins per tag, so it flaps with tag order — drivers.md's "latches Degraded forever" is wrong); S7Driver.cs:1278-1285 (PlcException incl. ReadData bad DB/address → Degraded). When every tag of a driver refuses (wrong DB number, wrong register map, gateway 0x0B for an offline RS-485 slave), LastSuccessfulRead freezes AND health is Degraded → the #527 conjunction trips every 90 s against a perfectly reachable device. Fix: map Modbus 01/02/03 and S7 non-fatal ReadData/WriteData to per-tag Bad + once-per-tag Warning with no health write (the AbCip rule); keep 04/05/06/0A/0B and connection-fatal codes on Degraded.

H5. S7: no driver-side lifecycle gate — overlapping InitializeAsync calls can leak an opened PLC connection and orphan a probe loop

Sev Medium · Conf Medium — S7Driver.cs:226-349 (no gate around the body; only _gate at :150 for I/O), :304 (Plc = plc), :328-333 (_probeCts/_probeTask assigned); DriverInstanceActor.cs:743, 807-855 (RetryConnect every 10 s with _initGeneration dropping only the result). ShutdownAsync drains (≤ 5 s poll + ≤ 5 s probe + ≤ 5 s gate) + connect (≤ 5 s) can exceed 10 s, so attempt B starts while A is in OpenAsync; A's Plc and probe loop are then never released (only the latest _probeCts is cancelled). Modbus has _lifecycleGate (ModbusDriver.cs:91-101) for exactly this. Fix: add the Modbus _lifecycleGate pattern around Initialize/Shutdown/Reinitialize.

H6. Calculation: a driver Restart leaves every calc tag BadWaitingForInitialData until each input happens to change

Sev Medium · Conf High — CalculationDriver.cs:87-88, 354, 475; DriverHostActor.cs:3811-3826 (Restart = new instance, only the restarted child re-subscribes); DependencyMuxActor.cs:43-88 (no last-value replay). Deploy is fine (poll drivers forceRaise on re-subscribe); Restart has no re-push, and values arriving before _running are dropped (:354). Fix: mux retains last (value,status,ts) per ref and replays on RegisterInterest; record inputs regardless of _running.

H10. AbCip: a missing/typo'd probe tag turns into a permanent device blackout — the probe loop records every exception as a Forward-Open failure

Sev High · Conf High — AbCipDriver.cs:666-679 (backoffVerdict = ex is AbCipTagConfigurationException ? null : false; — every other exception incl. AbTransportException(NotFound)), :700-701 (RecordFailure every Probe.Interval 5 s), :1442-1444 (data path fails fast while the window is open), :1482-1485 (the data path does classify NotFound as reachable — the probe does not), AbCipDriverProbe.cs:146-151, 164-167 (Test Connect reports the same situation green: "controller reachable; probe tag not found") With RecordFailure every 5 s, ComputeDelay saturates at 30 s so _nextAttemptUtc is always ≥ 25 s ahead and ShouldAttempt is never true: every cold runtime create throws ConnectionThrottledException → BadCommunicationError + Degraded for every tag not already hot, and after any single eviction a tag never comes back. Scenario: Probe.ProbeTagPath typo, or the probe tag deleted by a PLC download. Host Stopped, driver Degraded, all new/evicted tags dead indefinitely; a node restart does not help. Fix: classify ate.Status with IsReachableStatus in the probe catch — NotFound ⇒ verdict true (the session answered), one config Warning, and decide HostState separately from the backoff verdict.

H11. TwinCAT native subscribe is all-or-nothing: one bad symbol or one unreachable device yields zero data for the whole driver, with register/delete churn every 30 s and health still Healthy

Sev High · Conf High — TwinCATDriver.cs:690-706 (TwinCATNotificationRefusedException is the only per-tag catch), :710-721 (any other exception: dispose every registration + rethrow); AdsTwinCATClient.cs:540-542 (unknown symbol → InvalidOperationException), :584-586, :1214-1216/:1240-1248 (device offline / backoff → connect exception); TwinCATDriverOptions.cs:43 (UseNativeNotifications = true default) The host bulk-subscribes every tag in one call, so one typo'd symbolPath or one dead PLC in a multi-device driver tears down every notification already registered and fails the subscription; the health-tick reconcile retries every 30 s — a 2,000-tag driver issues 2,000 AddDeviceNotification + 2,000 DeleteDeviceNotification per 30 s forever, serving nothing, with _health untouched so the watchdog never fires. The poll fallback handles the same inputs per tag. Fix: catch Exception around RegisterNotificationAsync: one-off BadNodeIdUnknown/BadCommunicationError snapshot, store the intent as a dead registration so RetryDeadRegistrationsAsync picks it up; short-circuit a device-level connect failure for the rest of that device's tags.

H12. TwinCAT native mode: LastSuccessfulRead never advances, so one Degraded write latches a full driver reconnect; a router-level failure never degrades at all

Sev High · Conf High — TwinCATDriver.cs:681-683 (notification callback publishes Good without touching _health), :889-943 (probe only moves HostState), :176 (LastSuccessfulRead = init stamp); debits: :540-544 (any write exception incl. throttled connect → Degraded), :527-531 (NotSupportedException write → Degraded — the #611 "refusal is not a comms fault" case), :1294-1310 (replay failure, incl. a correctly-isolated type-mismatch tag re-attempted on every reconnect); DriverInstanceActor.cs:637-646 (silence measured from max(LastSuccessfulRead, _connectedSinceUtc)) On a connection older than 90 s, the first health tick after one Degraded write self-sends DisconnectObserved → ShutdownAsync + re-init + resubscribe: every ADS notification on every device deleted and re-registered because one write timed out. Inverse: an unreachable AMS router (client.ConnectAsync throws :1234-1248) is caught by the probe (:920-928) and never writes _health — values stay stale-Good, driver Healthy indefinitely. Fix: stamp LastSuccessfulRead (and restore Healthy) from the notification dispatch path (throttled Volatile.Write per batch) and from a successful probe; write Degraded with LastSuccessfulRead preserved after N consecutive probe/connect failures; drop the health write from the NotSupportedException branch.

H13. FOCAS: _devices is a plain Dictionary cleared and refilled in place under a live poll loop, the fixed-tree loop, the alarm loop and the actor health tick

Sev High · Conf High — FocasDriver.cs:46 (Dictionary<string, DeviceState>), :412 (_devices.Clear() in ReleaseDeviceStatesAsync), :225 (refill in InitializeAsync); readers :595 (ReadAsync TryGetValue), :1513 (foreach (var state in _devices.Values) — "Collection was modified"), :1145 (GetHostStatuses from the actor), :1779 (alarm loop). The driver explicitly keeps the poll engine alive across re-init (:49-50) and build-and-swaps the tag tables for that reason, but not _devices. TwinCAT uses ConcurrentDictionary (TwinCATDriver.cs:31-34). Same class, lower exposure: AbCip _devices/_devicesByRoutingKey (AbCipDriver.cs:101, 106, cleared :529-530 vs read :901 outside the try) and AbLegacy _devices + _tagsByRawPath (AbLegacyDriver.cs:31, 35, .Clear() at :259, 343 while the resolver reads it) — AbCip already swaps _tagsByRawPath by reference (:532) for exactly this reason. Fix: ConcurrentDictionary, or build-and-Volatile.Write-swap like _tagsByRawPath.

H14. FOCAS: macro/parameter numbers are narrowed to short after the capability matrix admitted up to 99999

Sev High · Conf High — Wire/WireFocasClient.cs:408 (_wire.ReadMacroAsync((short)address.Number, …)), :377-393 (parameters likewise); FocasCapabilityMatrix.cs:59-65 (30i/31i/32i macro range (0, 99999); Unknown → int.MaxValue); the wire format already carries Int32 (FocasWireProtocol.cs:285-288). MACRO:99999 → (short)99999 == -31073: the CNC is asked for a different variable and the result is published Good under the authored tag. Fix: widen the adapter signatures to int, or reject Number > short.MaxValue at init into the skipped-tag ledger.

H15. FOCAS: a driver serving only FIXED: tags never leaves Healthy when its CNC disappears

Sev Medium · Conf High — FocasDriver.cs:1243-1250, 1385-1391 (fixed-tree loop swallows every failure at Debug, never touches _health), :1181-1185 (probe likewise), :1544-1546 (deliberately does not credit), :611-621 (ReadAsync returns the fixed-tree snapshot without touching health), :1529-1530 (values go Bad after FixedTreeStaleAfter ≥ 30 s). /hosts shows a healthy driver with every node Bad; the #527 watchdog can never fire (the loops do self-reconnect, so recovery works — the health surface lies). Identity/Axes/Program/Status are the typical FOCAS deployment shape. Fix: after a successful axis pass write Healthy + LastSuccessfulRead; after a failed tick past FixedTreeStaleAfter write Degraded preserving it.

H16. AbLegacy: a deterministic refusal (array of String/Timer/Counter/Control) latches Degraded and churns handles every poll

Sev Medium · Conf High — AbLegacyDriver.cs:471-478 (generic catch → EvictRuntime + BadCommunicationError + Degraded), :597-601 (write path same), NativeAbLegacyTagRuntime.cs:411-414 / LibplctagLegacyTagRuntime.cs:200-206 (DecodeArray throws NotSupportedException; comment "the driver rejects them before reaching here" has no corresponding check); AbLegacyTagDefinitionFactory.cs:73-79 accepts isArray:true with any dataType. Every poll: create handle (one init read), decode throws, dispose, Bad, Degraded. Fix: catch NotSupportedException → BadNotSupported, keep the handle, no health write; reject the combination in FromTagConfig/Validate.

H17. AbCip: bit-in-word write hardcodes a DINT parent; INT/SINT hosts fail as comms faults

Sev Medium · Conf High — AbCipDriver.cs:1358, 1363 (DecodeValue(AbCipDataType.DInt) / EncodeValue(DInt) on the parent), :1385 (parent runtime created with no AuthoredType). Logix allows .n on SINT/INT/DINT; reads of Flags.3 on an INT host work, but the write's DecodeValue(DInt) on a 2-byte buffer throws OutOfBounds → generic catch :1322-1331 → EvictRuntime (the bit tag's, not the parent's), BadCommunicationError, Degraded. Fix: pick the parent width from GetSize()/CIP type code; map mismatch to BadTypeMismatch with no health write.

H18. TwinCAT: BOOL-within-word write is an RMW at a hardcoded 32-bit width with no parent check

Sev Medium · Conf Medium — TwinCATDriver.cs:492-511 (reads/writes the parent as UDInt); the notification path validates the bit against the real width (AdsTwinCATClient.cs:522-531, TwinCATBitAccess.CheckParent) but the write path does neither. A bit beyond a 16-bit parent is written into neighbouring bytes or rejected; a signed parent with the sign bit set reads back ≥ 2³¹ and a checked conversion → OverflowException → BadOutOfRange for any bit write. Writes were outside the TwinCAT live gate. Fix: resolve the parent via DescribeSymbolAsync, CheckParent, RMW on raw bytes of parent.ByteSize.

H19. AbCip write-side health: client conversion errors latch Degraded; a good write overwrites Degraded with Healthy

Sev Medium · Conf High — AbCipDriver.cs:1272-1296 (FormatException/InvalidCastException/OverflowException → Degraded; nothing touched the wire), :1186-1187, 1254 (Healthy on success, no worst-wins — undoing a Degraded the poll loop just recorded; the batch read path's ApplyBatchHealth :858-882 exists to avoid exactly this). AbLegacy maps the same three to BadTypeMismatch/BadOutOfRange with no health write (:602-609). Operator surface only (LastSuccessfulRead still advances on reads). Fix: drop the health writes from those catches and the success branches.

H20. FOCAS wire: ParseAlarms layout heuristic is ambiguous at 19 legacy records

Sev Low · Conf High — Wire/FocasWireClient.cs:1114-1121 picks the 76-byte layout whenever Length % 76 == 0; a legacy 80-byte reply of exactly 19 records (1,520 B) also satisfies it and decodes as 20 garbage vendor records.

H21. AbCip _health is not volatile (sibling says it is load-bearing)

Sev Low · Conf High — AbCipDriver.cs:136 vs AbLegacyDriver.cs:41-46 (whose comment explains why the actor-thread reader needs the barrier). Reference assignment is atomic and the reader crosses method calls, so fine in practice.

H7. Modbus: WriteAsync maps transport failures to BadInternalError; S7 probe reports the host Stopped on gate contention

Sev Low · Conf High — ModbusDriver.cs:1135-1146 (only InvalidDataException → BadCommunicationError; desync IOException, SocketException, timeout OCE → BadInternalError; encode OverflowException → BadInternalError not BadOutOfRange); S7Driver.cs:2499-2546 (2 s gateCts around _gate.WaitAsync → TransitionTo(Stopped) on a long read pass), S7DriverProbe.cs:74-76 ignores config TimeoutMs.

H8. Calculation: array-valued results never dedup

Sev Low · Conf High — CalculationDriver.cs:615 Equals(st.LastValue, value) is reference equality for arrays: a double[]-returning timer script republishes every tick through the dual-namespace fan-out and the historian. Fix: StructuralComparisons.StructuralEqualityComparer for Array.

H9. Modbus: SendOnceAsync indexes the response PDU without a length check

Sev Low · Conf High — ModbusTcpTransport.cs:157-171. respLen == 1 → respPdu[0] throws IndexOutOfRangeException (not an IOException, so no teardown/retry) → BadCommunicationError + Degraded for a malformed reply. Fix: treat respLen < 2 (< 3 for exception frames) as a TruncatedFrame desync.


5. Checked and fine (do not re-chase)

Ab.Transport (read in full by me):

  • EncapsulationClient: single-flight gate; caller cancel never faults the socket; every timeout finite (ValidateTimeout); Fault() closes the socket; DisposeAsync UnRegisters with a 500 ms budget; _gate deliberately never disposed (abandoned exchange may release late).
  • CipConnection: one shared open with OpenBudget; predecessor-closed chaining; one-shot keep-alive timer re-armed after each tick; Forward Close on dispose (1 s budget); RetireClosedByTargetAsync on 0x0107/0x0203 so a target-dropped class-3 connection reopens.
  • RequestScheduler: one pump per device (no cross-device stall); waiter tokens never reach the wire; MSP packing budgeted on request and expected-reply bytes; cancellation registrations disposed exactly once.
  • DeviceSessionPool: ref-counted leases, close chain prevents overlapping connections; ForwardOpenThrottle ≥ 1 s per device, never pruned (bounded by device count, documented).
  • LogixTagClient: fragmented read capped at 2 MiB; struct-handle change mid-read detected; fragmented write chunk alignment handles wide elements.
  • Decoders are bounds-checked: CipReader.Take, MultipleServicePacket.Parse (offset range), EncapsulationCodec.ParseReply (length/items), PcccServices.ParseReply (TNS before STS).
  • CipPath.EncodeRoute rejects malformed links/odd pairs; DeviceKey canonicalises the route.

Modbus: #527 dispose-before-overwrite in InitializeCoreAsync (:307-319) intact under _lifecycleGate; desync recovery tears the socket down before throwing (TCP :176-191, RTU ModbusRtuFraming.cs:125-165) and a coherent exception PDU does not; BitInRegister RMW serialised per register via _rmwLocks (bounded 65 536, no OS handle); NormalizeWordOrder correct for 8-byte types; block reads validate byte count before slicing; auto-prohibition bisection bounded and cleared on teardown.

S7: EnsureConnectedAsync under _gate with ConnectionBackoff (one attempt per window), dead handle disposed once, failed OpenAsync handle disposed; worst-state-wins health with AnySuccess advancing LastSuccessfulRead; access-denied → sticky Faulted + BadNotSupported without reconnect churn; value-range/encode errors → BadOutOfRange/BadTypeMismatch with no health impact; multi-var chunk 12 safe for 240-byte PDU with per-tag fallback; S7OperationDeadline bounds every async op; Bool/wide arrays and Timer/Counter writes refused up front; shutdown drains bounded; TSAP/CPU map/codecs refuse rather than clamp.

Calculation: timer Timers + CTS disposed in scheduler Dispose, StopTimers() at the top of every lifecycle entry (no stacking on re-init); lock order _evalGate → _lock honoured, publishes fire outside both; (value,status) dedup on the recovery path correct (:615, MarkInputsBad :639 per sub-code); ClearCompiledScripts vs in-flight run handled by the single retry on ObjectDisposedException; AdoptConfig clears _pending and drops removed tags' state; all input-cache access under _lock.

Cli / Contracts: all five subscribe commands unsubscribe in finally and reach ShutdownAsync; CLI flags land (--transport, --poll-only, --disable-reconnect); the only Options property no driver reads is ProbeTimeoutSeconds (consumed by AdminUI Test Connect); all six factories use TryReadEnumStrict; S7 NotSupportedExceptions at S7Driver.cs:1826-1830/2208-2212 are unreachable guards on the narrow path.

AbCip / AbLegacy: native lease balance — every acquire has a release on every path (NativeAbCipTagRuntime init :131-134 / Dispose :398; EnsureTagRuntimeAsync disposes on every init failure :1465, 1472, 1477, 1496; probe loop :674, 715; AbCipDriverProbe finally :178; NativeAbLegacyTagRuntime ctor acquire / InitializeOrKeepRefusedAsync :1069-1073; enumerator + template reader finally); ConnectionLease.DisposeAsync idempotent. LibplctagOrphanRelease wraps Initialize/Read/Write on both runtimes and the listing/template readers. AbCip _tagsByRawPath/_declaredTags swap by reference with volatile (:109, 112, 461-462, 532); DisposeHandles disposes runtimes, parent runtimes and both semaphore tables (:2057-2069); ConnectGate intentionally not disposed (:1951-1958). Alarm projection subscriptions retired out-of-band with CTSs disposed; told-records pruned by RetainOnly per subscribe; warned-once sets bounded by the authored set. AbCipTemplateCache never pruned — bounded by the controller's UDT count, staleness documented. AbCip read/write refusal classification (TypeMismatch, Unsupported, config exception, NotSupportedException on write) keeps the handle and leaves health alone; batch read health is worst-wins with LastSuccessfulRead advancing on any success. AbLegacy WriteRefusal before runtime creation (:546-550) + LibplctagLegacyName consistent across transports. Native STRING decode clamps LEN and handles the 88-byte padding; bit access and Span bounds-checked. GetHostStatuses() from the actor is try/catch-guarded (CurrentHostStatuses). IRediscoverable absent on AbCip is a documented gap.

TwinCAT / FOCAS: ADS notification/variable handle lifetimes are balanced — NativeRegistration.Dispose deletes the AMS notification (TwinCATDriver.cs:875-880 → AdsTwinCATClient.cs:646-651), replay swaps and disposes the old handle (:1281-1282), replay and probe-retry retract an orphan created by a racing unsubscribe (:1286-1291, 1348-1353), ShutdownAsync/Dispose dispose registrations before clients, AdsTwinCATClient.Dispose detaches the event, stops the dispatch loop and disposes the AdsClient (:821-835); ReadRawAsync variable handles deleted in finally (:421-434); failed connect disposes its client (:1240-1248); stale client disposed before replacement (:1219-1224); RecycleClientAsync runs under the gate. Sum-read classification matches #645. Codecs: TwinCATStringCodec NUL-terminates with 1252/UTF-8 by target; TwinCATArrayCodec throws on a short buffer and the notification path drops rather than publishes (:612-615); TwinCATTemporalCodec.Encode range-checks. FOCAS re-init/shutdown: all three loops cancelled + bounded-joined (5 s), CTSs disposed, client disposed; alarm projection paused not re-created; tag tables built fresh and swapped (:238-318); connect serialised per device with a ReferenceEquals guard (:1855-1865). FOCAS alarm health contract (#647) implemented as documented (:485-499). FOCAS wire parsers bounds-check via RequireLength; a mid-request failure closes both sockets; ReadExactlyAsync disposes the stream on cancellation; SynchronizedFocasClient pairs the gate with a per-call timeout. PMC coalescing memo bounded by the tag set. AdsTwinCATClient.ConnectAsync ignoring the token for the blocking SDK Connect is documented as the un-fixed root. Unverified assumption worth a wire capture: DescribeSymbolAsync per read relies on the Beckhoff symbol cache making ReadSymbolAsync local after the first call (AdsTwinCATClient.cs:368-372).

PollGroupEngine (shared): one task per subscription; only the loop's own token exits; cancel + join (≤ 5 s) before CTS dispose; LastValues per subscription; reader-contract violation throws loudly.

Runtime seam: DriverInstanceActor.PostStop calls ShutdownAsync only — the sync IDisposable.Dispose() overloads (GetAwaiter().GetResult()) on AbCip/Modbus/FOCAS have no production caller.