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:
Joseph Doherty
2026-05-22 06:40:54 -04:00
parent 5197b6c237
commit 8a7668c678
14 changed files with 428 additions and 63 deletions

View File

@@ -23,7 +23,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource, IDisposable, IAsyncDisposable
{
private readonly AbCipDriverOptions _options;
private AbCipDriverOptions _options;
private readonly string _driverInstanceId;
private readonly IAbCipTagFactory _tagFactory;
private readonly IAbCipTagEnumeratorFactory _enumeratorFactory;
@@ -32,7 +32,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
private readonly PollGroupEngine _poll;
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, AbCipTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
private readonly AbCipAlarmProjection _alarmProjection;
private AbCipAlarmProjection _alarmProjection;
private DriverHealth _health = new(DriverState.Unknown, null, null);
public event EventHandler<DataChangeEventArgs>? OnDataChange;
@@ -108,11 +108,32 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
public string DriverInstanceId => _driverInstanceId;
public string DriverType => "AbCip";
/// <summary>
/// Initialize the driver from its <c>DriverConfig</c> JSON. When
/// <paramref name="driverConfigJson"/> carries a real configuration (any device or tag),
/// it is parsed via <see cref="AbCipDriverFactoryExtensions.ParseOptions"/> and the
/// parsed options REPLACE the construction-time options — this is what makes
/// <see cref="ReinitializeAsync"/> pick up a changed config (new device, new tag,
/// changed timeout). A blank or empty-object JSON (<c>"{}"</c>) is treated as "no
/// override" so callers that constructed the driver with explicit options — chiefly
/// unit tests — keep those options. The driver's address-space + runtime state is then
/// built from the effective <see cref="_options"/>.
/// </summary>
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
_health = new DriverHealth(DriverState.Initializing, null, null);
try
{
if (!string.IsNullOrWhiteSpace(driverConfigJson))
{
var parsed = AbCipDriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson);
if (parsed.Devices.Count > 0 || parsed.Tags.Count > 0)
{
_options = parsed;
_alarmProjection = new AbCipAlarmProjection(this, _options.AlarmPollInterval);
}
}
foreach (var device in _options.Devices)
{
var addr = AbCipHostAddress.TryParse(device.HostAddress)
@@ -147,7 +168,9 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
{
state.ProbeCts = new CancellationTokenSource();
var ct = state.ProbeCts.Token;
_ = Task.Run(() => ProbeLoopAsync(state, ct), ct);
// Keep the loop Task so ShutdownAsync can await its clean exit before
// disposing the CTS / handles the loop is still using (Driver.AbCip-008).
state.ProbeTask = Task.Run(() => ProbeLoopAsync(state, ct), ct);
}
}
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
@@ -166,15 +189,46 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Tear the driver down: stop the alarm projection + poll engine, then for each device
/// cancel its probe loop, <em>await the loop's clean exit</em>, and only then dispose
/// the probe CTS + runtime handles. Awaiting the probe Task before disposing closes the
/// race where a still-running loop touches a disposed CTS or a cleared runtime
/// dictionary (Driver.AbCip-008). Idempotent — safe to call twice (e.g. ShutdownAsync
/// from ReinitializeAsync followed by DisposeAsync).
/// </summary>
public async Task ShutdownAsync(CancellationToken cancellationToken)
{
await _alarmProjection.DisposeAsync().ConfigureAwait(false);
await _poll.DisposeAsync().ConfigureAwait(false);
// Phase 1: signal every probe loop to stop.
foreach (var state in _devices.Values)
{
try { state.ProbeCts?.Cancel(); } catch (ObjectDisposedException) { }
}
// Phase 2: wait for each probe loop to observe cancellation and exit. The loop never
// throws on cancellation (it catches OperationCanceledException internally), but guard
// anyway so one slow device can't wedge the whole shutdown.
foreach (var state in _devices.Values)
{
var probeTask = state.ProbeTask;
if (probeTask is null) continue;
try
{
await probeTask.WaitAsync(TimeSpan.FromSeconds(10), cancellationToken).ConfigureAwait(false);
}
catch (TimeoutException) { }
catch (OperationCanceledException) { }
}
// Phase 3: now the loops are gone, dispose the CTS + native handles with no live reader.
foreach (var state in _devices.Values)
{
try { state.ProbeCts?.Cancel(); } catch { }
state.ProbeCts?.Dispose();
state.ProbeCts = null;
state.ProbeTask = null;
state.DisposeHandles();
}
_devices.Clear();
@@ -316,7 +370,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// <summary>
/// Read each <c>fullReference</c> in order. Unknown tags surface as
/// <c>BadNodeIdUnknown</c>; libplctag-layer failures map through
/// <see cref="AbCipStatusMapper.MapLibplctagStatus"/>; any other exception becomes
/// <see cref="AbCipStatusMapper.MapLibplctagStatus(int)"/>; any other exception becomes
/// <c>BadCommunicationError</c>. The driver health surface is updated per-call so the
/// Admin UI sees a tight feedback loop between read failures + the driver's state.
/// </summary>
@@ -331,8 +385,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// whole-UDT read + in-memory member decode; every other reference falls back to the
// per-tag path that's been here since PR 3. Planner is a pure function over the
// current tag map; BOOL/String/Structure members stay on the fallback path because
// declaration-only offsets can't place them under Logix alignment rules.
var plan = AbCipUdtReadPlanner.Build(fullReferences, _tagsByName);
// declaration-only offsets can't place them under Logix alignment rules. Whole-UDT
// grouping is itself gated behind EnableDeclarationOnlyUdtGrouping — Studio 5000 may
// reorder UDT members vs declaration order, so the fast path is opt-in only (see
// Driver.AbCip-003 / AbCipUdtMemberLayout remarks).
var plan = AbCipUdtReadPlanner.Build(
fullReferences, _tagsByName, _options.EnableDeclarationOnlyUdtGrouping);
foreach (var group in plan.Groups)
await ReadGroupAsync(group, results, now, cancellationToken).ConfigureAwait(false);
@@ -609,8 +667,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
runtime.Dispose();
throw;
}
device.ParentRuntimes[parentTagName] = runtime;
return runtime;
// Two concurrent callers can both miss the cache + both initialize a runtime; only the
// first TryAdd wins. Dispose the loser so it doesn't leak a native tag handle.
if (device.ParentRuntimes.TryAdd(parentTagName, runtime))
return runtime;
runtime.Dispose();
return device.ParentRuntimes[parentTagName];
}
/// <summary>
@@ -643,8 +705,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
runtime.Dispose();
throw;
}
device.Runtimes[def.Name] = runtime;
return runtime;
// Two concurrent callers can both miss the cache + both initialize a runtime; only the
// first TryAdd wins. Dispose the loser so it doesn't leak a native tag handle.
if (device.Runtimes.TryAdd(def.Name, runtime))
return runtime;
runtime.Dispose();
return device.Runtimes[def.Name];
}
public DriverHealth GetHealth() => _health;
@@ -803,14 +869,26 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
public CancellationTokenSource? ProbeCts { get; set; }
public bool ProbeInitialized { get; set; }
/// <summary>
/// The fire-and-forget probe loop's <see cref="Task"/>. Stored so
/// <see cref="AbCipDriver.ShutdownAsync"/> can await the loop's clean exit after
/// cancelling <see cref="ProbeCts"/> and BEFORE disposing the CTS or the runtime
/// handles — otherwise the still-running loop can touch a disposed CTS or a cleared
/// runtime dictionary (Driver.AbCip-008).
/// </summary>
public Task? ProbeTask { get; set; }
public Dictionary<string, PlcTagHandle> TagHandles { get; } =
new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Per-tag runtime handles owned by this device. One entry per configured tag is
/// created lazily on first read (see <see cref="AbCipDriver.EnsureTagRuntimeAsync"/>).
/// <see cref="System.Collections.Concurrent.ConcurrentDictionary{TKey,TValue}"/>
/// because <c>ReadAsync</c> is invoked concurrently by the server read path, every
/// polled subscription loop, and the alarm projection loop.
/// </summary>
public Dictionary<string, IAbCipTagRuntime> Runtimes { get; } =
public System.Collections.Concurrent.ConcurrentDictionary<string, IAbCipTagRuntime> Runtimes { get; } =
new(StringComparer.OrdinalIgnoreCase);
/// <summary>
@@ -819,7 +897,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// bit-selector tag name ("Motor.Flags.3") needs a distinct handle from the DINT
/// parent ("Motor.Flags") used to do the read + write.
/// </summary>
public Dictionary<string, IAbCipTagRuntime> ParentRuntimes { get; } =
public System.Collections.Concurrent.ConcurrentDictionary<string, IAbCipTagRuntime> ParentRuntimes { get; } =
new(StringComparer.OrdinalIgnoreCase);
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, SemaphoreSlim> _rmwLocks = new();

View File

@@ -21,6 +21,20 @@ public static class AbCipDriverFactoryExtensions
}
internal static AbCipDriver CreateInstance(string driverInstanceId, string driverConfigJson)
{
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
var options = ParseOptions(driverInstanceId, driverConfigJson);
return new AbCipDriver(options, driverInstanceId);
}
/// <summary>
/// Deserialise an AB CIP driver-config JSON document into <see cref="AbCipDriverOptions"/>.
/// Shared by <see cref="CreateInstance"/> (first construction) and
/// <see cref="AbCipDriver.InitializeAsync"/> / <see cref="AbCipDriver.ReinitializeAsync"/>
/// so a reinitialize with a changed config JSON (new device, new tag, changed timeout)
/// actually takes effect rather than being silently discarded.
/// </summary>
internal static AbCipDriverOptions ParseOptions(string driverInstanceId, string driverConfigJson)
{
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
@@ -29,7 +43,7 @@ public static class AbCipDriverFactoryExtensions
?? throw new InvalidOperationException(
$"AB CIP driver config for '{driverInstanceId}' deserialised to null");
var options = new AbCipDriverOptions
return new AbCipDriverOptions
{
Devices = dto.Devices is { Count: > 0 }
? [.. dto.Devices.Select(d => new AbCipDeviceOptions(
@@ -53,9 +67,8 @@ public static class AbCipDriverFactoryExtensions
EnableControllerBrowse = dto.EnableControllerBrowse ?? false,
EnableAlarmProjection = dto.EnableAlarmProjection ?? false,
AlarmPollInterval = TimeSpan.FromMilliseconds(dto.AlarmPollIntervalMs ?? 1_000),
EnableDeclarationOnlyUdtGrouping = dto.EnableDeclarationOnlyUdtGrouping ?? false,
};
return new AbCipDriver(options, driverInstanceId);
}
private static AbCipTagDefinition BuildTag(AbCipTagDto t, string driverInstanceId) =>
@@ -108,6 +121,7 @@ public static class AbCipDriverFactoryExtensions
public int? TimeoutMs { get; init; }
public bool? EnableControllerBrowse { get; init; }
public bool? EnableAlarmProjection { get; init; }
public bool? EnableDeclarationOnlyUdtGrouping { get; init; }
public int? AlarmPollIntervalMs { get; init; }
public List<AbCipDeviceDto>? Devices { get; init; }
public List<AbCipTagDto>? Tags { get; init; }

View File

@@ -56,6 +56,20 @@ public sealed class AbCipDriverOptions
/// 1 second — matches typical SCADA alarm-refresh conventions.
/// </summary>
public TimeSpan AlarmPollInterval { get; init; } = TimeSpan.FromSeconds(1);
/// <summary>
/// Opt-in for the declaration-only whole-UDT read fast path. When <c>false</c> (the
/// default) a batch of UDT members is always read per-member, because the byte offsets
/// computed by <see cref="AbCipUdtMemberLayout"/> assume the controller lays members
/// out in declaration order — and the Studio 5000 compiler does NOT guarantee that
/// (it reorders for largest-first packing, BOOL host bytes, nested-struct padding).
/// Decoding at declaration-order offsets against a reordered controller layout yields
/// silently-plausible wrong numbers. Set <c>true</c> only when the operator has
/// hand-verified that every configured UDT's member declaration order matches the
/// controller's compiled layout; in that case whole-UDT grouping collapses N member
/// reads into one. The richer CIP Template Object path remains the long-term fix.
/// </summary>
public bool EnableDeclarationOnlyUdtGrouping { get; init; }
}
/// <summary>

View File

@@ -1,3 +1,5 @@
using libplctag;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
@@ -24,8 +26,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// writes during download / test-mode transitions).</item>
/// <item>0x16 object does not exist — <c>BadNodeIdUnknown</c>.</item>
/// <item>0x1E embedded service error — unwrap to the extended status when possible.</item>
/// <item>any libplctag <c>PLCTAG_STATUS_*</c> below zerowrapped as
/// <c>BadCommunicationError</c> until fine-grained mapping lands (PR 3).</item>
/// <item>libplctag.NET <see cref="Status"/> errorsmapped per-member by
/// <see cref="MapLibplctagStatus(Status)"/>: timeout, not-found, not-allowed, and
/// out-of-bounds get their specific OPC UA codes; the remaining transport errors
/// fold into <c>BadCommunicationError</c>.</item>
/// </list>
/// </remarks>
public static class AbCipStatusMapper
@@ -58,22 +62,34 @@ public static class AbCipStatusMapper
};
/// <summary>
/// Map a libplctag return/status code (<c>PLCTAG_STATUS_*</c>) to an OPC UA StatusCode.
/// libplctag uses <c>0 = PLCTAG_STATUS_OK</c>, positive values for pending, negative
/// values for errors.
/// Map a libplctag return/status code to an OPC UA StatusCode. The integer passed here
/// is <c>(int)Tag.GetStatus()</c> — i.e. the underlying value of the libplctag.NET
/// <see cref="Status"/> enum, NOT a raw native <c>PLCTAG_ERR_*</c> constant. The wrapper
/// renumbers the native codes into a contiguous enum, so this method switches on the
/// <see cref="Status"/> members directly to stay correct if the wrapper renumbers again.
/// <see cref="Status.Ok"/> is success; <see cref="Status.Pending"/> is an in-flight
/// operation; every other (negative) member is an error.
/// </summary>
public static uint MapLibplctagStatus(int status)
public static uint MapLibplctagStatus(int status) => MapLibplctagStatus((Status)status);
/// <summary>
/// Map a libplctag.NET <see cref="Status"/> enum value to an OPC UA StatusCode. This is
/// the strongly-typed core of the mapper; the <c>int</c> overload exists only for the
/// <see cref="IAbCipTagRuntime.GetStatus"/> seam, which returns the boxed-as-int value.
/// </summary>
public static uint MapLibplctagStatus(Status status) => status switch
{
if (status == 0) return Good;
if (status > 0) return GoodMoreData; // PLCTAG_STATUS_PENDING
return status switch
{
-5 => BadTimeout, // PLCTAG_ERR_TIMEOUT
-7 => BadCommunicationError, // PLCTAG_ERR_BAD_CONNECTION
-14 => BadNodeIdUnknown, // PLCTAG_ERR_NOT_FOUND
-16 => BadNotWritable, // PLCTAG_ERR_NOT_ALLOWED / read-only tag
-17 => BadOutOfRange, // PLCTAG_ERR_OUT_OF_BOUNDS
_ => BadCommunicationError,
};
}
Status.Ok => Good,
Status.Pending => GoodMoreData,
Status.ErrorTimeout => BadTimeout,
Status.ErrorNotFound or Status.ErrorNoMatch or Status.ErrorBadDevice => BadNodeIdUnknown,
Status.ErrorNotAllowed => BadNotWritable,
Status.ErrorOutOfBounds or Status.ErrorTooLarge or Status.ErrorTooSmall => BadOutOfRange,
Status.ErrorUnsupported or Status.ErrorNotImplemented => BadNotSupported,
Status.ErrorBadConnection or Status.ErrorBadGateway or Status.ErrorBadReply
or Status.ErrorWinsock or Status.ErrorOpen or Status.ErrorClose
or Status.ErrorRead or Status.ErrorWrite or Status.ErrorRemoteErr
or Status.ErrorPartial or Status.ErrorAbort => BadCommunicationError,
_ => BadCommunicationError,
};
}

View File

@@ -8,17 +8,27 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// list that <see cref="AbCipDriver.ReadAsync"/> runs through its existing read path.
/// Pure function — the planner never touches the runtime + never reads the PLC.
/// </summary>
/// <remarks>
/// The grouped offsets come from <see cref="AbCipUdtMemberLayout"/>, which assumes the
/// controller lays members out in declaration order. Studio 5000 does not guarantee that,
/// so grouping is gated behind <see cref="AbCipDriverOptions.EnableDeclarationOnlyUdtGrouping"/>:
/// when grouping is disabled every member falls back to its own per-tag read.
/// </remarks>
public static class AbCipUdtReadPlanner
{
/// <summary>
/// Split <paramref name="requests"/> into whole-UDT groups + per-tag leftovers.
/// <paramref name="tagsByName"/> is the driver's <c>_tagsByName</c> map — both parent
/// UDT rows and their fanned-out member rows live there. Lookup is OrdinalIgnoreCase
/// to match the driver's dictionary semantics.
/// to match the driver's dictionary semantics. When
/// <paramref name="enableDeclarationOnlyGrouping"/> is <c>false</c> no groups are
/// formed — every reference goes to the per-tag fallback path so member decoding never
/// relies on declaration-order offsets that may not match the controller layout.
/// </summary>
public static AbCipUdtReadPlan Build(
IReadOnlyList<string> requests,
IReadOnlyDictionary<string, AbCipTagDefinition> tagsByName)
IReadOnlyDictionary<string, AbCipTagDefinition> tagsByName,
bool enableDeclarationOnlyGrouping = false)
{
ArgumentNullException.ThrowIfNull(requests);
ArgumentNullException.ThrowIfNull(tagsByName);
@@ -26,6 +36,13 @@ public static class AbCipUdtReadPlanner
var fallback = new List<AbCipUdtReadFallback>(requests.Count);
var byParent = new Dictionary<string, List<AbCipUdtReadMember>>(StringComparer.OrdinalIgnoreCase);
if (!enableDeclarationOnlyGrouping)
{
for (var i = 0; i < requests.Count; i++)
fallback.Add(new AbCipUdtReadFallback(i, requests[i]));
return new AbCipUdtReadPlan([], fallback);
}
for (var i = 0; i < requests.Count; i++)
{
var name = requests[i];

View File

@@ -20,7 +20,7 @@ public interface IAbCipTagRuntime : IDisposable
/// <summary>
/// Raw libplctag status code — mapped to an OPC UA StatusCode via
/// <see cref="AbCipStatusMapper.MapLibplctagStatus"/>. Zero on success, negative on error.
/// <see cref="AbCipStatusMapper.MapLibplctagStatus(int)"/>. Zero on success, negative on error.
/// </summary>
int GetStatus();