- Driver.AbCip-007: inject an optional ILogger<AbCipDriver> / ILogger<AbCipAlarmProjection> (default NullLogger) and log around every read / write / template-fetch / probe / alarm-poll failure path. - Driver.AbCip-011: LogWarning when InitializeAsync is configured with Probe.Enabled=true but ProbeTagPath is blank — operators now see why GetHostStatuses keeps reporting Unknown. - Driver.AbCip-012: documented the LibplctagTemplateReader per-call Tag cost as accepted given libplctag's own connection pool and the low-frequency discovery use-case. - Driver.AbCip-013: per-device AllowPacking + ConnectionSize overrides on AbCipDeviceOptions, threaded through AbCipTagCreateParams; central BuildCreateParams helper replaces five ad-hoc clones; AllowPacking now reaches Tag.AllowPacking at runtime. - Driver.AbCip-015: stale-comment sweep — every PR-N forward-reference is rewritten to describe present behaviour. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
31 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 | 0 |
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 | Resolved |
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: Resolved 2026-05-22 — ToDriverDataType now maps LInt→Int64, ULInt→UInt64, UDInt→UInt32 (all already present in DriverDataType); DecodeValueAt updated to return uint/ulong for UDInt/ULInt respectively so the declared type and runtime value agree. The (int) and (long) casts that caused truncation/wrap are removed.
Driver.AbCip-005
| Field | Value |
|---|---|
| Severity | Medium |
| Category | Correctness & logic bugs |
| Location | AbCipDriver.cs:124-141 |
| Status | Resolved |
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: Resolved 2026-05-22 — The parent Structure tag remains in _tagsByName so the whole-UDT grouping planner (Driver.AbCip-003 fast path) and alarm projection can still find it. ReadSingleAsync now detects a direct read of a Structure-with-Members and returns BadNotSupported instead of Good/null, documenting that callers must address individual member paths. Both scalar and member fan-out registration perform a duplicate-key check that throws InvalidOperationException naming both colliding entries (fail-fast, consistent with AbCipHostAddress validation).
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 | Resolved |
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: Resolved 2026-05-22 — PlcTagHandle.cs deleted; DeviceState.TagHandles removed from DeviceState; its DisposeHandles loop cleaned up. The class-level doc comment on AbCipDriver updated to document that native lifetime is owned by libplctag.NET Tag.Dispose() (called in DisposeHandles) with the library's own finalizer covering GC-collected instances. The two dead-code test methods for PlcTagHandle removed from AbCipDriverTests.
Driver.AbCip-007
| Field | Value |
|---|---|
| Severity | Low |
| Category | OtOpcUa conventions |
| Location | AbCipDriver.cs (whole file), AbCipAlarmProjection.cs, LibplctagTagRuntime.cs |
| Status | Resolved |
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: Resolved 2026-05-23 — AbCipDriver and AbCipAlarmProjection now accept an optional ILogger<AbCipDriver> / ILogger (defaulting to NullLogger so the existing constructor surface stays compatible). Failure paths log through it: InitializeAsync (LogError on fault), ReadSingleAsync / ReadGroupAsync / WriteAsync (LogWarning on non-zero libplctag status + transport / type-conversion exceptions, with the affected tag + device on each entry), ProbeLoopAsync (LogDebug per swallowed tick), FetchUdtShapeAsync (LogWarning on template-read failure), and AbCipAlarmProjection.RunPollLoopAsync (LogDebug on swallowed tick). Six regression tests in AbCipLoggingTests exercise the new logger seam.
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 | Resolved |
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: Resolved 2026-05-22 — already addressed as part of the Driver.AbCip-008 fix: DeviceState.Runtimes and ParentRuntimes were switched to ConcurrentDictionary; both EnsureTagRuntimeAsync and EnsureParentRuntimeAsync use the TryGetValue → create → TryAdd → dispose-loser pattern so concurrent callers that both miss the cache produce exactly one live runtime and the losing creator is disposed rather than leaked.
Driver.AbCip-010
| Field | Value |
|---|---|
| Severity | Medium |
| Category | Error handling & resilience |
| Location | AbCipDriver.cs:621-648, AbCipDriver.cs:346-391 |
| Status | Resolved |
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: Resolved 2026-05-22 — added EvictRuntime(device, tagName) helper that calls ConcurrentDictionary.TryRemove + disposes the evicted instance; called from ReadSingleAsync, ReadGroupAsync, and WriteAsync on both non-zero libplctag status and transport exceptions (type/value-conversion exceptions are not transport faults and do not evict). The next read/write for the affected tag re-runs EnsureTagRuntimeAsync, which creates and initializes a fresh handle, mirroring the probe loop's recreate-on-failure behaviour.
Driver.AbCip-011
| Field | Value |
|---|---|
| Severity | Low |
| Category | Error handling & resilience |
| Location | AbCipDriver.cs:144-152, AbCipDriverOptions.cs:131-143 |
| Status | Resolved |
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: Resolved 2026-05-23 — InitializeAsync now emits a LogWarning when Probe.Enabled is true, devices are configured, but Probe.ProbeTagPath is null/blank. The warning names the driver instance and explicitly states that no probe loops were started and GetHostStatuses() will report every device as Unknown until either a ProbeTagPath is set or Probe.Enabled is set to false. Initialization still succeeds (the probe is optional telemetry, not a hard requirement). Two AbCipLoggingTests cases cover the warn-on-enabled-but-blank and no-warn-on-disabled paths. The AbCipProbeOptions.ProbeTagPath doc-comment was also updated so the misconfiguration is documented in-place.
Driver.AbCip-012
| Field | Value |
|---|---|
| Severity | Low |
| Category | Performance & resource management |
| Location | LibplctagTemplateReader.cs:15-35, AbCipDriver.cs:88-92 |
| Status | Resolved |
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: Resolved 2026-05-23 — accepted per the recommendation's "document the per-type connect cost is accepted" branch; AbCipTemplateCache caches the decoded shape so only the first fetch per (device, templateInstanceId) pays the connect cost, and libplctag itself pools the underlying CIP connections per gateway+path so the TCP/EIP session is reused even when individual Tag instances are torn down. The class-level remarks on LibplctagTemplateReader now spell that out and call out when to revisit (telemetry showing discovery latency dominated by template-read connects).
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 | Resolved |
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: Resolved 2026-05-23 — took the "expose per-device options per the spec" branch. AbCipDeviceOptions now carries optional AllowPacking and ConnectionSize overrides (both default to null to inherit the family profile); AbCipTagCreateParams carries the resolved values; DeviceState.BuildCreateParams collapses every old per-call-site clone (read, write, probe, template, enumerator) into one helper that combines the per-device override with the family profile's SupportsRequestPacking / DefaultConnectionSize defaults. LibplctagTagRuntime now honours AllowPacking via the Tag.AllowPacking property — fixing the previously-dead family-profile setting. ConnectionSize is plumbed through AbCipTagCreateParams for forward-compat; libplctag.NET 1.5.2 has no direct ConnectionSize property, so an XML comment on LibplctagTagRuntime documents that current builds rely on the family-profile default at the wire layer until the wrapper exposes a direct property or we ship a custom tag-attribute path. AbCipDriverFactoryExtensions ParseOptions now reads AllowPacking + ConnectionSize from the driver-config JSON. Six regression tests in AbCipPerDeviceConnectionOptionsTests cover the new options.
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 | Resolved |
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: Resolved 2026-05-22 — status mapper test already uses real Status enum members (fixed with Driver.AbCip-002); ReinitializeAsync config-change coverage already added with Driver.AbCip-001. Added to AbCipDriverCodeReviewRegressionTests: three tests for 004 (LInt/ULInt/UDInt type-mapping theory + UDInt decoded-as-uint assertion), three tests for 005 (Structure parent read returns BadNotSupported, duplicate scalar key throws, member-collision-with-independent-tag throws), and one test for 010 (eviction on bad status means next read creates a fresh handle). AbCipDriverTests.AbCipDataType_maps_atomics_to_driver_types extended with LInt/ULInt/UDInt assertions.
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 | Resolved |
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: Resolved 2026-05-23 — swept the module for stale PR-N forward references and replaced each with a description of present behaviour: AbCipDriver.TemplateCache summary, AbCipDataType.cs (PR 5 / PR 6 → references CipTemplateObjectDecoder + AbCipTemplateCache), AbCipTagPath.cs (PR 6 → references AbCipTemplateCache), AbCipTemplateCache.cs (the "lands with PR 6" remarks and the AbCipUdtShape summary), IAbCipTagEnumerator.cs (the EmptyAbCipTagEnumeratorFactory-defaults claim and the PR-5 stub line; EmptyAbCipTagEnumerator summary), LibplctagTagEnumerator.cs ("Task #178 closed the stub gap from PR 5"), LibplctagTagRuntime.cs (Whole-UDT writes land in PR 6), AbCipDriverOptions.cs (Tags summary, ProbeTagPath summary), and AbCipPlcFamilyProfile.cs ("Family-specific wire tests ship in PRs 9–12"). PlcTagHandle.cs was already deleted as part of Driver.AbCip-006's resolution. The only remaining "lands in" reference is the AbCipDataType.Dt ⇒ Date/Time mapping, which is product-domain wording, not a PR reference.