fix(driver-abcip): resolve High code-review findings (Driver.AbCip-001, -002, -003, -008)
Driver.AbCip-001 — ReinitializeAsync silently discarded its config JSON. Extracted AbCipDriverFactoryExtensions.ParseOptions; InitializeAsync now re-parses a content-bearing driverConfigJson and replaces _options (and recreates the alarm projection), so a reinitialize with a changed config (new device/tag, changed timeout) actually takes effect. A blank or empty-object JSON keeps construction-time options for the unit-test seam. Driver.AbCip-002 — libplctag status mapping used wrong integer constants. MapLibplctagStatus now switches on the libplctag.NET Status enum members (Ok/Pending/ErrorTimeout/ErrorNotFound/ErrorNotAllowed/ErrorOutOfBounds/…) instead of hand-typed natives, so timeout/not-found/not-allowed/out-of-bounds get their specific OPC UA codes instead of all collapsing to BadCommunicationError. The int overload casts to Status to stay correct against the wrapper's contiguous renumbering. Driver.AbCip-003 — whole-UDT reads decoded members at declaration-order offsets, which Studio 5000 does not guarantee. Added the opt-in AbCipDriverOptions.EnableDeclarationOnlyUdtGrouping flag (default false); AbCipUdtReadPlanner.Build forms no groups when it is off, so by default every UDT member reads per-tag rather than at possibly-wrong offsets. Driver.AbCip-008 — probe loops were fire-and-forget and ShutdownAsync raced them. Each probe Task is stored on DeviceState.ProbeTask; ShutdownAsync now cancels every CTS, awaits each probe Task (10s timeout), then disposes the CTS and handles. DeviceState.Runtimes/ParentRuntimes are ConcurrentDictionary and the Ensure*RuntimeAsync paths use TryAdd, disposing the losing concurrent creator instead of leaking a native tag handle. Adds AbCipDriverCodeReviewRegressionTests and updates existing AbCip tests to the corrected status constants + opt-in grouping flag. AbCip driver + test project build clean; all 244 AbCip tests pass. (The full-solution build has pre-existing, unrelated Driver.Galaxy protobuf-generation errors in this worktree.) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,7 +7,7 @@
|
||||
| Review date | 2026-05-22 |
|
||||
| Commit reviewed | `76d35d1` |
|
||||
| Status | Reviewed |
|
||||
| Open findings | 15 |
|
||||
| Open findings | 11 |
|
||||
|
||||
## Checklist coverage
|
||||
|
||||
@@ -33,13 +33,13 @@
|
||||
| Severity | High |
|
||||
| Category | Correctness & logic bugs |
|
||||
| Location | `AbCipDriver.cs:111`, `AbCipDriver.cs:163-167` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `InitializeAsync(string driverConfigJson, ...)` never reads `driverConfigJson`. It builds all device/tag state from `_options`, captured at construction time. `ReinitializeAsync` calls `ShutdownAsync` then `InitializeAsync(driverConfigJson, ...)` and the JSON it is handed is silently discarded. `ReinitializeAsync` is documented (class remarks, lines 18-21) as the Tier-B escape hatch and is the IDriver entry point for picking up changed config. As written, a reinitialize with an updated config JSON (new device, new tag, changed timeout) applies none of the changes; the driver keeps running stale construction-time options. There is no validation that the passed JSON even matches the live options.
|
||||
|
||||
**Recommendation:** Either parse `driverConfigJson` inside `InitializeAsync` (re-deriving `AbCipDriverOptions` the way `AbCipDriverFactoryExtensions.CreateInstance` does, so config changes take effect on reinit), or, if config is intentionally immutable for the instance lifetime, document explicitly that AbCip ignores the parameter and assert the JSON is structurally identical to the construction options. Silently discarding it is the worst of both.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** Resolved 2026-05-22 — extracted `AbCipDriverFactoryExtensions.ParseOptions` and `InitializeAsync` now re-parses a content-bearing `driverConfigJson`, replacing `_options` (and recreating the alarm projection) so `ReinitializeAsync` applies config changes; a blank/empty-object JSON keeps construction-time options for the test seam.
|
||||
|
||||
### Driver.AbCip-002
|
||||
|
||||
@@ -48,13 +48,13 @@
|
||||
| Severity | High |
|
||||
| Category | Correctness & logic bugs |
|
||||
| Location | `AbCipStatusMapper.cs:65-78` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `MapLibplctagStatus` maps negative libplctag codes that do not match the libplctag.NET `Status` enum / native `libplctag.h` constants. `LibplctagTagRuntime.GetStatus()` returns `(int)_tag.GetStatus()`, the underlying value of the `Status` enum, whose members carry the native `PLCTAG_ERR_*` integer values. The real constants are `PLCTAG_ERR_BAD_CONNECTION = -7` (the only one the code gets right), `PLCTAG_ERR_NOT_FOUND = -18` (code expects -14), `PLCTAG_ERR_NOT_ALLOWED = -19` (code expects -16), `PLCTAG_ERR_OUT_OF_BOUNDS = -22` (code expects -17), `PLCTAG_ERR_TIMEOUT = -32` (code expects -5). Consequently a real timeout, not-found, not-allowed, or out-of-bounds error all fall through the switch to the `_ => BadCommunicationError` default. The driver reports `BadCommunicationError` for a non-existent tag instead of `BadNodeIdUnknown`, for a read-only tag instead of `BadNotWritable`, and for a timeout instead of `BadTimeout`. This defeats the transient-vs-permanent error classification the resilience pipeline relies on.
|
||||
|
||||
**Recommendation:** Replace the hand-typed integer literals with the libplctag.NET `Status` enum members (Status.ErrorTimeout, Status.ErrorNotFound, Status.ErrorNotAllowed, Status.ErrorOutOfBounds, Status.ErrorBadConnection, etc.), or at minimum correct the integer values to -32 / -18 / -19 / -22. Map Status.Pending explicitly rather than treating "any positive value" as GoodMoreData.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** Resolved 2026-05-22 — `MapLibplctagStatus` now switches on the libplctag.NET `Status` enum members (Ok/Pending/ErrorTimeout/ErrorNotFound/ErrorNotAllowed/ErrorOutOfBounds/…) instead of hand-typed integers; the `int` overload casts to `Status` so the `GetStatus()` seam stays correct against the wrapper's contiguous renumbering. Note: the live libplctag.NET 1.5.2 `Status` enum is renumbered contiguously, so the correct underlying integers are -32/-19/-18/-27, not the native -32/-18/-19/-22 the finding suggested; switching on the enum members sidesteps the hazard entirely.
|
||||
|
||||
### Driver.AbCip-003
|
||||
|
||||
@@ -63,13 +63,13 @@
|
||||
| Severity | High |
|
||||
| Category | Correctness & logic bugs |
|
||||
| Location | `AbCipUdtMemberLayout.cs:32-54`, `AbCipDriver.cs:426-430`, `AbCipUdtReadPlanner.cs:48` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** The whole-UDT read path (`ReadGroupAsync`) decodes each grouped member at the byte offset produced by `AbCipUdtMemberLayout.TryBuild`, which computes offsets purely from declaration order of the configured `AbCipStructureMember` list under natural-alignment rules. Logix does not guarantee that the controller lays UDT members out in declaration order: the Studio 5000 compiler reorders members (largest-first packing, BOOL host bytes, nested-struct padding) and the on-wire offsets only come from the CIP Template Object. The class remarks on `AbCipUdtMemberLayout` and `driver-specs.md` both acknowledge this. The decoder for the real shape (`CipTemplateObjectDecoder` / `AbCipTemplateCache`) exists and is populated by `FetchUdtShapeAsync`, but `ReadGroupAsync` never consults it: it always uses the declaration-only layout. For any UDT whose member declaration order in config differs from the controller compiled layout, whole-UDT reads return values decoded from the wrong offsets, silently plausible wrong numbers.
|
||||
|
||||
**Recommendation:** In the read planner / `ReadGroupAsync`, prefer the cached `AbCipUdtShape` offsets (from `AbCipTemplateCache` / `FetchUdtShapeAsync`) when available, and only fall back to `AbCipUdtMemberLayout` declaration-order offsets when no template shape can be read. Even then, consider gating the declaration-only fast path behind an explicit opt-in flag, since it is correct only when the operator has hand-verified declaration order matches the controller.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** Resolved 2026-05-22 — the declaration-only whole-UDT grouping fast path is now gated behind the new opt-in `AbCipDriverOptions.EnableDeclarationOnlyUdtGrouping` flag (default `false`); `AbCipUdtReadPlanner.Build` forms no groups when it is off, so by default every UDT member reads per-tag instead of decoding at possibly-wrong declaration-order offsets. The richer CIP Template Object path remains the long-term fix.
|
||||
|
||||
### Driver.AbCip-004
|
||||
|
||||
@@ -138,13 +138,13 @@
|
||||
| Severity | High |
|
||||
| Category | Concurrency & thread safety |
|
||||
| Location | `AbCipDriver.cs:144-152`, `AbCipDriver.cs:169-183`, `AbCipDriver.cs:235-281` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** Probe loops are started fire-and-forget (`_ = Task.Run(() => ProbeLoopAsync(state, ct), ct)`) and the resulting Task is never stored or awaited. `ShutdownAsync` cancels `state.ProbeCts`, then immediately disposes it, sets it null, and calls `state.DisposeHandles()` without waiting for `ProbeLoopAsync` to observe the cancellation and exit. Races: (1) the still-running probe loop may be mid-await against a `ProbeCts` that `ShutdownAsync` has already disposed, producing `ObjectDisposedException` on the loop thread; (2) `DisposeHandles` clears `Runtimes`/`ParentRuntimes` while a concurrent `ReadAsync`/`WriteAsync` from the alarm projection or a subscription poll could be iterating or adding to those plain `Dictionary` instances (not thread-safe), corrupting the dictionary or throwing; (3) the probe runtime created inside `ProbeLoopAsync` is never tracked by `DeviceState`, so `DisposeHandles` cannot dispose it; only the loop own finally does, which may run after `ShutdownAsync` returns.
|
||||
|
||||
**Recommendation:** Store each probe Task on `DeviceState`; in `ShutdownAsync` cancel the CTS, then await Task.WhenAll (with a timeout) before disposing the CTS or the handles. Guard `Runtimes`/`ParentRuntimes` with a lock or switch to `ConcurrentDictionary`. Make `ShutdownAsync` idempotent and safe against in-flight `ReadAsync`/`WriteAsync`.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** Resolved 2026-05-22 — each probe loop's `Task` is stored on `DeviceState.ProbeTask`; `ShutdownAsync` now runs three phases (cancel every CTS, then await each probe Task with a 10s timeout, then dispose the CTS + handles) so the loop never touches a disposed CTS or cleared dictionary. `DeviceState.Runtimes` / `ParentRuntimes` are now `ConcurrentDictionary`, and `EnsureTagRuntimeAsync` / `EnsureParentRuntimeAsync` use `TryAdd` and dispose the losing concurrent creator instead of leaking it. `ShutdownAsync` stays idempotent (a second call sees the cleared `_devices`).
|
||||
|
||||
### Driver.AbCip-009
|
||||
|
||||
|
||||
Reference in New Issue
Block a user