ad2b5c4044
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
278 lines
50 KiB
Markdown
278 lines
50 KiB
Markdown
# 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.
|