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

278 lines
50 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`
```csharp
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`
```csharp
_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 `TcpClient`s 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 `DeviceState`s (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)
```csharp
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`
```csharp
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`
```csharp
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`
```csharp
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 `Timer`s + 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 `NotSupportedException`s 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.