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>
24 KiB
Code Review — Driver.AbCip
| Field | Value |
|---|---|
| Module | src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip |
| Reviewer | Claude Code |
| Review date | 2026-05-22 |
| Commit reviewed | 76d35d1 |
| Status | Reviewed |
| Open findings | 11 |
Checklist coverage
| # | Category | Result |
|---|---|---|
| 1 | Correctness & logic bugs | Driver.AbCip-001, Driver.AbCip-002, Driver.AbCip-003, Driver.AbCip-004, Driver.AbCip-005 |
| 2 | OtOpcUa conventions | Driver.AbCip-006, Driver.AbCip-007 |
| 3 | Concurrency & thread safety | Driver.AbCip-008, Driver.AbCip-009 |
| 4 | Error handling & resilience | Driver.AbCip-010, Driver.AbCip-011 |
| 5 | Security | No issues found |
| 6 | Performance & resource management | Driver.AbCip-012 |
| 7 | Design-document adherence | Driver.AbCip-013 |
| 8 | Code organization & conventions | No issues found |
| 9 | Testing coverage | Driver.AbCip-014 |
| 10 | Documentation & comments | Driver.AbCip-015 |
Findings
Driver.AbCip-001
| Field | Value |
|---|---|
| Severity | High |
| Category | Correctness & logic bugs |
| Location | AbCipDriver.cs:111, AbCipDriver.cs:163-167 |
| 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: 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
| Field | Value |
|---|---|
| Severity | High |
| Category | Correctness & logic bugs |
| Location | AbCipStatusMapper.cs:65-78 |
| 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: 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
| Field | Value |
|---|---|
| Severity | High |
| Category | Correctness & logic bugs |
| Location | AbCipUdtMemberLayout.cs:32-54, AbCipDriver.cs:426-430, AbCipUdtReadPlanner.cs:48 |
| 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: 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
| Field | Value |
|---|---|
| Severity | Medium |
| Category | Correctness & logic bugs |
| Location | AbCipDataType.cs:51-58, LibplctagTagRuntime.cs:47-49,53 |
| Status | Open |
Description: ToDriverDataType maps LInt/ULInt to DriverDataType.Int32 (a TODO comment notes the gap) and Dt to Int32. But LibplctagTagRuntime.DecodeValueAt returns an actual long for LInt/ULInt (_tag.GetInt64, (long)_tag.GetUInt64). The address space is built declaring an Int32 node while the driver hands the server a boxed long DataValueSnapshot.Value at runtime: a mismatch between the declared OPC UA data type and the runtime value type. For LInt values exceeding Int32.MaxValue there is data loss if any consumer narrows it. UDInt is declared Int32 but decoded as (int)_tag.GetUInt32, so values above int.MaxValue wrap to negative.
Recommendation: Either add Int64/UInt32/UInt64 to DriverDataType and map correctly, or, until that lands, decode LInt/ULInt consistently with the declared Int32 type (and document the truncation), and decode UDInt as a value that fits Int32 semantics. The declared type and the runtime value type must agree.
Resolution: (open)
Driver.AbCip-005
| Field | Value |
|---|---|
| Severity | Medium |
| Category | Correctness & logic bugs |
| Location | AbCipDriver.cs:124-141 |
| Status | Open |
Description: In InitializeAsync, when a Structure tag declares Members, the loop registers each fanned-out member into _tagsByName but the parent Structure tag itself is also left in _tagsByName (added at line 125 before the member check). A subsequent ReadAsync of the parent name routes through ReadSingleAsync then DecodeValue(AbCipDataType.Structure, ...) which returns null with Good status. A client reading the parent UDT node thus gets a Good/null snapshot rather than a fault or a structured value. Also, member registration does not check for name collisions: if two configured tags produce the same parent-dot-member key (or a member name collides with an independently-declared tag), the later silently overwrites the earlier with no diagnostic.
Recommendation: Decide the parent-Structure read contract explicitly: either do not register the bare parent name as a readable tag, or have the Structure read return a proper status. Add a duplicate-key check during _tagsByName population that throws an InvalidOperationException naming both colliding tags, consistent with the fail-fast validation AbCipHostAddress parsing already does.
Resolution: (open)
Driver.AbCip-006
| Field | Value |
|---|---|
| Severity | Medium |
| Category | OtOpcUa conventions |
| Location | PlcTagHandle.cs:28-59, AbCipDriver.cs:806-807,832-833, LibplctagTagRuntime.cs:117 |
| Status | Open |
Description: driver-specs.md makes the SafeHandle-wrapped native handle a non-negotiable Tier-B protection ("Wrap every libplctag handle in a SafeHandle with finalizer calling plc_tag_destroy"). The repo ships PlcTagHandle : SafeHandle for this, but it is dead code: ReleaseHandle is a permanent no-op (the comment says the plc_tag_destroy P/Invoke "is deferred to PR 3", well past the commit under review), and DeviceState.TagHandles is never populated anywhere in the driver. The real native lifetime is delegated to the libplctag.NET Tag object own Dispose(). The mandated finalizer-backed leak protection therefore does not exist: if a LibplctagTagRuntime is GC-collected without Dispose (owning thread crashes, exception bypasses the device dispose path), whether the native tag is freed depends entirely on whether libplctag.NET Tag has its own finalizer, which is not guaranteed by this driver code as the design requires.
Recommendation: Either delete PlcTagHandle and DeviceState.TagHandles as misleading dead scaffolding and document that native lifetime is owned by libplctag.NET Tag finalizer (verifying that Tag actually has one), or finish the intended design by making LibplctagTagRuntime hold a real PlcTagHandle with a working ReleaseHandle calling plc_tag_destroy.
Resolution: (open)
Driver.AbCip-007
| Field | Value |
|---|---|
| Severity | Low |
| Category | OtOpcUa conventions |
| Location | AbCipDriver.cs (whole file), AbCipAlarmProjection.cs, LibplctagTagRuntime.cs |
| Status | Open |
Description: CLAUDE.md Library Preferences mandate Serilog with a rolling daily file sink. The driver has no logging at all: no ILogger/Serilog dependency is injected or used. Failure paths instead swallow exceptions into the _health string (ReadSingleAsync, WriteAsync, FetchUdtShapeAsync catch-all, ProbeLoopAsync empty catch, AbCipAlarmProjection.RunPollLoopAsync empty catch). An operator looking at server logs sees nothing for a probe loop failing every tick for hours, a template decode that silently returned null, or an alarm poll loop throwing every interval. The health surface carries only the last error message, so a transient error immediately overwrites a more important earlier one.
Recommendation: Inject an ILogger (Serilog) and log at least device init failures, per-call read/write transport errors (debounced), probe-loop failures, template-read failures, and alarm-poll-loop exceptions. The health surface is for state, not for the audit trail.
Resolution: (open)
Driver.AbCip-008
| Field | Value |
|---|---|
| Severity | High |
| Category | Concurrency & thread safety |
| Location | AbCipDriver.cs:144-152, AbCipDriver.cs:169-183, AbCipDriver.cs:235-281 |
| 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: 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
| Field | Value |
|---|---|
| Severity | Medium |
| Category | Concurrency & thread safety |
| Location | AbCipDriver.cs:621-648, AbCipDriver.cs:591-614 |
| Status | Open |
Description: EnsureTagRuntimeAsync and EnsureParentRuntimeAsync are check-then-act on a non-thread-safe Dictionary (device.Runtimes / device.ParentRuntimes). ReadAsync is IReadable and may be invoked concurrently: the server read path, each polled subscription loop, and the alarm projection poll loop all call ReadAsync independently. Two concurrent ReadAsync calls that both miss the cache for the same tag both create a LibplctagTagRuntime, both initialize it, and both write into the dictionary; the loser leaks an initialized native tag (never disposed, since only the dictionary value is disposed at shutdown), and concurrent Dictionary mutation can throw or corrupt the bucket structure. WriteBitInDIntAsync serializes the parent via a per-parent SemaphoreSlim, but EnsureParentRuntimeAsync still runs the same unguarded check-then-act on the shared ParentRuntimes dict.
Recommendation: Use ConcurrentDictionary for Runtimes and ParentRuntimes, creating the runtime via GetOrAdd with a lazily-initialized factory, or guard the ensure path with a per-device lock / SemaphoreSlim. Ensure the losing creator runtime is disposed rather than leaked.
Resolution: (open)
Driver.AbCip-010
| Field | Value |
|---|---|
| Severity | Medium |
| Category | Error handling & resilience |
| Location | AbCipDriver.cs:621-648, AbCipDriver.cs:346-391 |
| Status | Open |
Description: Once EnsureTagRuntimeAsync successfully creates and initializes a LibplctagTagRuntime, that runtime is cached for the lifetime of the device and never re-created on failure. If the underlying native tag enters a permanently-bad state (connection dropped, controller rebooted, tag handle invalidated by a PLC program download), every subsequent ReadAsync/WriteAsync reuses the same dead handle and returns errors forever. The probe loop does tear down and recreate its runtime after a failure, but the read/write path has no equivalent recovery; only a full ReinitializeAsync (itself broken, see Driver.AbCip-001) clears the cache. The normal data path should self-heal from a transient handle fault without operator-driven reinitialize.
Recommendation: On a non-zero libplctag status or transport exception in ReadSingleAsync/ReadGroupAsync/WriteAsync, evict the offending runtime from device.Runtimes (and dispose it) so the next call re-creates and re-initializes it. Mirror the probe loop recreate-on-failure behavior.
Resolution: (open)
Driver.AbCip-011
| Field | Value |
|---|---|
| Severity | Low |
| Category | Error handling & resilience |
| Location | AbCipDriver.cs:144-152, AbCipDriverOptions.cs:131-143 |
| Status | Open |
Description: InitializeAsync only starts probe loops when _options.Probe.Enabled is true AND Probe.ProbeTagPath is non-blank. When Probe.Enabled is true (the default) but ProbeTagPath is null (also the default; the doc comment says "PR 8 wires this up"), no probe runs at all and the device HostState stays HostState.Unknown forever. GetHostStatuses() then reports every device as Unknown indefinitely with no warning. An operator who enables the probe but does not set a probe tag gets a silently inert health surface rather than an error or a log line.
Recommendation: When Probe.Enabled is true but no ProbeTagPath is configured, either fail initialization with a clear message, fall back to a family-default probe tag (the doc comment stated intent), or at minimum log a warning that the probe is enabled-but-inert.
Resolution: (open)
Driver.AbCip-012
| Field | Value |
|---|---|
| Severity | Low |
| Category | Performance & resource management |
| Location | LibplctagTemplateReader.cs:15-35, AbCipDriver.cs:88-92 |
| Status | Open |
Description: LibplctagTemplateReader is created per FetchUdtShapeAsync call, and each call constructs a fresh libplctag Tag for the @udt pseudo-tag, initializes it (a CIP connection handshake), reads, and disposes it. There is no reuse of the Tag across template reads for the same device: every UDT shape fetch pays a full connect/init cost. AbCipTemplateCache caches the decoded shape so this only bites on the first fetch of each type, but discovery of a UDT-heavy controller still does one connect per type. The same per-call Tag construction applies to LibplctagTagEnumerator.
Recommendation: Acceptable for a low-frequency discovery path, but consider pooling/reusing a single @udt-capable Tag per device for the duration of a discovery run, or document that the per-type connect cost is accepted.
Resolution: (open)
Driver.AbCip-013
| Field | Value |
|---|---|
| Severity | Low |
| Category | Design-document adherence |
| Location | AbCipDriverOptions.cs:70-73, PlcFamilies/AbCipPlcFamilyProfile.cs:13-19, LibplctagTagRuntime.cs:16-27 |
| Status | Open |
Description: driver-specs.md specifies the AB CIP per-device connection settings as discrete fields: Host, Path, PlcType, TimeoutMs, AllowPacking, ConnectionSize. The implementation instead collapses host + path into a single opaque ab:// URL string and exposes PlcFamily (which adds GuardLogix, not in the spec table). AllowPacking and ConnectionSize from the spec are not configurable per device: AbCipPlcFamilyProfile hard-codes SupportsRequestPacking and DefaultConnectionSize per family, and LibplctagTagRuntime never passes a connection-size or packing attribute to the Tag (it is constructed with only Gateway/Path/PlcType/Protocol/Name/Timeout). The family profile DefaultConnectionSize/SupportsRequestPacking/MaxFragmentBytes fields are computed but never applied to the wire layer: dead configuration.
Recommendation: Either update driver-specs.md to describe the actual ab:// host-address model and the family-profile approach, and wire the profile ConnectionSize/packing values through to the libplctag Tag attributes; or expose AllowPacking/ConnectionSize as per-device options per the spec.
Resolution: (open)
Driver.AbCip-014
| Field | Value |
|---|---|
| Severity | Medium |
| Category | Testing coverage |
| Location | tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipStatusMapperTests.cs:28-40 |
| Status | Open |
Description: AbCipStatusMapperTests.MapLibplctagStatus_maps_known_codes asserts the mapper against the same wrong integer constants (-5, -7, -14, -16, -17) the production code uses (see Driver.AbCip-002). The test locks in the bug rather than catching it, giving false confidence that libplctag error mapping is correct. There is no test that drives an actual libplctag Status enum value through LibplctagTagRuntime.GetStatus() plus MapLibplctagStatus end-to-end. Separately, the broken ReinitializeAsync config-discard behavior (Driver.AbCip-001) and the declaration-order whole-UDT decode risk (Driver.AbCip-003) have no test that would fail when those defects are present: AbCipDriverWholeUdtReadTests only exercises a UDT whose declaration order happens to match a simple alignment layout.
Recommendation: Rewrite the libplctag-status test to use the real libplctag.Status enum members and their documented integer values. Add a test that ReinitializeAsync with a changed config JSON actually applies the change (or asserts the documented immutability contract). Add a whole-UDT decode test where the controller compiled layout differs from declaration order.
Resolution: (open)
Driver.AbCip-015
| Field | Value |
|---|---|
| Severity | Low |
| Category | Documentation & comments |
| Location | AbCipDriver.cs:9-11, PlcTagHandle.cs:23-27,53-58, AbCipTemplateCache.cs:12-15, IAbCipTagEnumerator.cs:6-11, AbCipDriverOptions.cs:21 |
| Status | Open |
Description: Numerous comments are stale relative to the commit under review. AbCipDriver.cs:9-11 says the driver "Implements IDriver only for now" with capabilities shipping "in subsequent PRs (3-8)" while the class already implements all of them. PlcTagHandle.cs says the plc_tag_destroy P/Invoke "is deferred to PR 3 ... PR 2 ships the lifetime scaffold + tests only" and ReleaseHandle "is a no-op", which now reads as a permanent unfinished-work marker (see Driver.AbCip-006). AbCipTemplateCache.cs:12-15 says "Template shape read ... lands with PR 6 ... no reader writes to it yet" while CipTemplateObjectDecoder and LibplctagTemplateReader both exist and FetchUdtShapeAsync writes to the cache. IAbCipTagEnumerator.cs:6-11 says the enumerator "Defaults to EmptyAbCipTagEnumeratorFactory" while the production default is LibplctagTagEnumeratorFactory. AbCipDriverOptions.cs:21 says "AB discovery lands in PR 5", already shipped. StyleGuide.md explicitly says not to leave stale coming-soon notes.
Recommendation: Sweep the module for PR-N forward references and "lands in PR X" notes that have been delivered; update them to describe present behavior. Where a comment marks genuinely unfinished work (e.g. PlcTagHandle.ReleaseHandle), convert it to a tracked TODO with an issue reference rather than a PR-number milestone.
Resolution: (open)