fix(driver-twincat): resolve High code-review findings (Driver.TwinCAT-001, -002, -007, -008, -013)

Driver.TwinCAT-001 — InitializeAsync/ReinitializeAsync ignored driverConfigJson.
Extracted the DTO-to-options parse into a shared TwinCATDriverFactoryExtensions.ParseOptions;
InitializeAsync now re-parses driverConfigJson into a mutable _options field, so a config
generation pushed via ReinitializeAsync (added/removed devices, tags, probe settings) is
actually applied at runtime.

Driver.TwinCAT-002 — LInt/ULInt narrowed to Int32. ToDriverDataType now maps LInt to Int64,
ULInt to UInt64, UDInt to UInt32, UInt/USInt to UInt16, Int/SInt to Int16, and the IEC
TIME/DATE/DT/TOD types to UInt32 (their raw UDINT counter). Removed the stale "Int64 gap"
comment — no truncation or sign flips at the OPC UA encode layer.

Driver.TwinCAT-007 — EnsureConnectedAsync was not thread-safe. Connect/reconnect is now
serialized per device by a SemaphoreSlim (DeviceState.ConnectGate) with a double-checked
connect, mirroring the S7 driver. Concurrent read/write/probe callers can no longer leak a
client or race a create-vs-dispose.

Driver.TwinCAT-008 — native ADS notification callbacks ran driver logic on the AMS router
thread. AdsTwinCATClient now enqueues AdsNotificationEx callbacks onto a bounded Channel
drained by a dedicated managed task; the router-thread callback only does a non-blocking
TryWrite, so a slow consumer cannot stall ADS notification delivery process-wide.

Driver.TwinCAT-013 — TwinCATDriver did not implement IRediscoverable. The driver now
implements IRediscoverable; AdsTwinCATClient detects ADS 0x0702 (symbol-version-changed) on
read/write paths and raises OnSymbolVersionChanged, which the driver forwards as
OnRediscoveryNeeded so Core rebuilds the address space after a PLC program re-download.

Adds TwinCATHighFindingsRegressionTests covering all five fixes; updates the data-type
mapping assertion in TwinCATDriverTests. TwinCAT driver builds clean; 119 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-22 06:37:05 -04:00
parent 66e8bfbab3
commit 5197b6c237
10 changed files with 400 additions and 37 deletions

View File

@@ -1,5 +1,6 @@
using System.Collections.Concurrent;
using System.Runtime.CompilerServices;
using System.Threading.Channels;
using TwinCAT;
using TwinCAT.Ads;
using TwinCAT.Ads.TypeSystem;
@@ -21,16 +22,50 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
/// </remarks>
internal sealed class AdsTwinCATClient : ITwinCATClient
{
// Bounded so a slow downstream consumer cannot back the AMS router thread up — the
// router thread enqueues and returns immediately (Driver.TwinCAT-008). 50k matches the
// Galaxy EventPump default; ~500 notifications/connection is the ADS ceiling so this is
// generous headroom against bursty change storms.
private const int NotificationQueueCapacity = 50_000;
private readonly AdsClient _client = new();
private readonly ConcurrentDictionary<uint, NotificationRegistration> _notifications = new();
// Marshals native ADS notifications off the AMS router thread onto a dedicated managed
// task. The router-thread callback (OnAdsNotificationEx) only enqueues; DispatchLoopAsync
// drains and invokes the per-tag OnChange delegates. Blocking the router thread would
// stall every ADS notification across the whole process (docs/v2/driver-specs.md §6).
private readonly Channel<PendingNotification> _notificationQueue =
Channel.CreateBounded<PendingNotification>(new BoundedChannelOptions(NotificationQueueCapacity)
{
FullMode = BoundedChannelFullMode.DropWrite,
SingleReader = true,
SingleWriter = false,
});
private readonly CancellationTokenSource _dispatchCts = new();
private readonly Task _dispatchLoop;
private int _disposed;
public AdsTwinCATClient()
{
_client.AdsNotificationEx += OnAdsNotificationEx;
_dispatchLoop = Task.Run(() => DispatchLoopAsync(_dispatchCts.Token));
}
private readonly record struct PendingNotification(NotificationRegistration Registration, object? Value);
public bool IsConnected => _client.IsConnected;
public event EventHandler? OnSymbolVersionChanged;
/// <summary>Raise <see cref="OnSymbolVersionChanged"/> when <paramref name="adsError"/> is 0x0702.</summary>
private uint MapAndSignal(uint adsError)
{
if (TwinCATStatusMapper.IsSymbolVersionChanged(adsError))
OnSymbolVersionChanged?.Invoke(this, EventArgs.Empty);
return TwinCATStatusMapper.MapAdsError(adsError);
}
public Task ConnectAsync(TwinCATAmsAddress address, TimeSpan timeout, CancellationToken cancellationToken)
{
if (_client.IsConnected) return Task.CompletedTask;
@@ -60,7 +95,7 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
var parentResult = await _client.ReadValueAsync(parent, typeof(uint), cancellationToken)
.ConfigureAwait(false);
if (parentResult.ErrorCode != AdsErrorCode.NoError)
return (null, TwinCATStatusMapper.MapAdsError((uint)parentResult.ErrorCode));
return (null, MapAndSignal((uint)parentResult.ErrorCode));
return (ExtractBit(parentResult.Value, bit), TwinCATStatusMapper.Good);
}
@@ -69,13 +104,13 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
.ConfigureAwait(false);
if (result.ErrorCode != AdsErrorCode.NoError)
return (null, TwinCATStatusMapper.MapAdsError((uint)result.ErrorCode));
return (null, MapAndSignal((uint)result.ErrorCode));
return (result.Value, TwinCATStatusMapper.Good);
}
catch (AdsErrorException ex)
{
return (null, TwinCATStatusMapper.MapAdsError((uint)ex.ErrorCode));
return (null, MapAndSignal((uint)ex.ErrorCode));
}
}
@@ -106,11 +141,11 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
.ConfigureAwait(false);
return result.ErrorCode == AdsErrorCode.NoError
? TwinCATStatusMapper.Good
: TwinCATStatusMapper.MapAdsError((uint)result.ErrorCode);
: MapAndSignal((uint)result.ErrorCode);
}
catch (AdsErrorException ex)
{
return TwinCATStatusMapper.MapAdsError((uint)ex.ErrorCode);
return MapAndSignal((uint)ex.ErrorCode);
}
}
@@ -159,13 +194,35 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
return reg;
}
/// <summary>
/// Runs on the <see cref="AdsClient"/> AMS router thread. Does the cheap bit-extraction
/// decode then enqueues — no driver logic, no consumer callbacks — so a slow consumer
/// can never stall ADS notification delivery for the rest of the process
/// (Driver.TwinCAT-008). Drops the notification (DropWrite) if the queue is saturated.
/// </summary>
private void OnAdsNotificationEx(object? sender, AdsNotificationExEventArgs args)
{
if (!_notifications.TryGetValue(args.Handle, out var reg)) return;
var value = args.Value;
if (reg.BitIndex is int bit && reg.Type == TwinCATDataType.Bool && value is not bool)
value = ExtractBit(value, bit);
try { reg.OnChange(reg.SymbolPath, value); } catch { /* consumer-side errors don't crash the ADS thread */ }
_notificationQueue.Writer.TryWrite(new PendingNotification(reg, value));
}
private async Task DispatchLoopAsync(CancellationToken ct)
{
try
{
await foreach (var pending in _notificationQueue.Reader.ReadAllAsync(ct).ConfigureAwait(false))
{
try { pending.Registration.OnChange(pending.Registration.SymbolPath, pending.Value); }
catch { /* consumer-side errors stay on this managed task, not the ADS thread */ }
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
// Clean shutdown.
}
}
internal async Task DeleteNotificationAsync(uint handle, CancellationToken cancellationToken)
@@ -236,8 +293,17 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0) return;
_client.AdsNotificationEx -= OnAdsNotificationEx;
_notifications.Clear();
// Stop the dispatch loop: complete the queue so the reader drains + exits, then
// cancel as a backstop. Best-effort wait so a wedged consumer can't hang teardown.
_notificationQueue.Writer.TryComplete();
_dispatchCts.Cancel();
try { _dispatchLoop.Wait(TimeSpan.FromSeconds(2)); } catch { /* shutdown */ }
_dispatchCts.Dispose();
_client.Dispose();
}

View File

@@ -19,6 +19,16 @@ public interface ITwinCATClient : IDisposable
/// <summary>True when the AMS router + target both accept commands.</summary>
bool IsConnected { get; }
/// <summary>
/// Raised when the client observes the ADS symbol-version-changed code
/// (<see cref="TwinCATStatusMapper.AdsSymbolVersionChanged"/>) on any read / write /
/// notification — the signal that a PLC program re-download has invalidated every
/// symbol + notification handle. The driver forwards this to
/// <see cref="Core.Abstractions.IRediscoverable.OnRediscoveryNeeded"/> so Core rebuilds
/// the address space subtree (docs/v2/driver-specs.md §6, Driver.TwinCAT-013).
/// </summary>
event EventHandler? OnSymbolVersionChanged;
/// <summary>
/// Read a symbolic value. Returns a boxed .NET value matching the requested
/// <paramref name="type"/>, or <c>null</c> when the read produced no data; the

View File

@@ -34,15 +34,19 @@ public static class TwinCATDataTypeExtensions
public static DriverDataType ToDriverDataType(this TwinCATDataType t) => t switch
{
TwinCATDataType.Bool => DriverDataType.Boolean,
TwinCATDataType.SInt or TwinCATDataType.USInt
or TwinCATDataType.Int or TwinCATDataType.UInt
or TwinCATDataType.DInt or TwinCATDataType.UDInt => DriverDataType.Int32,
TwinCATDataType.LInt or TwinCATDataType.ULInt => DriverDataType.Int32, // matches Int64 gap
TwinCATDataType.SInt => DriverDataType.Int16, // signed 8-bit — no narrower OPC UA atom
TwinCATDataType.USInt => DriverDataType.UInt16, // unsigned 8-bit — no narrower OPC UA atom
TwinCATDataType.Int => DriverDataType.Int16,
TwinCATDataType.UInt => DriverDataType.UInt16,
TwinCATDataType.DInt => DriverDataType.Int32,
TwinCATDataType.UDInt => DriverDataType.UInt32,
TwinCATDataType.LInt => DriverDataType.Int64,
TwinCATDataType.ULInt => DriverDataType.UInt64,
TwinCATDataType.Real => DriverDataType.Float32,
TwinCATDataType.LReal => DriverDataType.Float64,
TwinCATDataType.String or TwinCATDataType.WString => DriverDataType.String,
TwinCATDataType.Time or TwinCATDataType.Date
or TwinCATDataType.DateTime or TwinCATDataType.TimeOfDay => DriverDataType.Int32,
or TwinCATDataType.DateTime or TwinCATDataType.TimeOfDay => DriverDataType.UInt32,
TwinCATDataType.Structure => DriverDataType.String,
_ => DriverDataType.Int32,
};

View File

@@ -9,9 +9,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
/// resolver land in PRs 2 and 3.
/// </summary>
public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
IHostConnectivityProbe, IPerCallHostResolver, IDisposable, IAsyncDisposable
IHostConnectivityProbe, IPerCallHostResolver, IRediscoverable, IDisposable, IAsyncDisposable
{
private readonly TwinCATDriverOptions _options;
// Mutable so ReinitializeAsync can apply a new config generation (Driver.TwinCAT-001).
// The constructor seeds it; InitializeAsync re-parses driverConfigJson over the top of it.
private TwinCATDriverOptions _options;
private readonly string _driverInstanceId;
private readonly ITwinCATClientFactory _clientFactory;
private readonly PollGroupEngine _poll;
@@ -21,6 +23,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
public event EventHandler<DataChangeEventArgs>? OnDataChange;
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
public event EventHandler<RediscoveryEventArgs>? OnRediscoveryNeeded;
public TwinCATDriver(TwinCATDriverOptions options, string driverInstanceId,
ITwinCATClientFactory? clientFactory = null)
@@ -43,6 +46,16 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
_health = new DriverHealth(DriverState.Initializing, null, null);
try
{
// Apply the supplied config generation (Driver.TwinCAT-001). A blank or content-free
// document keeps the constructor-seeded options — that path covers callers that have
// already materialised options up front (the factory passes both, in agreement).
if (!string.IsNullOrWhiteSpace(driverConfigJson))
{
var parsed = TwinCATDriverFactoryExtensions.ParseOptions(driverConfigJson, _driverInstanceId);
if (parsed.Devices.Count > 0 || parsed.Tags.Count > 0)
_options = parsed;
}
foreach (var device in _options.Devices)
{
var addr = TwinCATAmsAddress.TryParse(device.HostAddress)
@@ -92,6 +105,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
state.ProbeCts?.Dispose();
state.ProbeCts = null;
state.DisposeClient();
state.DisposeGate();
}
_devices.Clear();
_tagsByName.Clear();
@@ -410,24 +424,64 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
return _options.Devices.FirstOrDefault()?.HostAddress ?? DriverInstanceId;
}
/// <summary>
/// Lazily connect a device's client, serialized per device by
/// <see cref="DeviceState.ConnectGate"/> (Driver.TwinCAT-007). Without the gate, a
/// concurrent read / write / probe could each create + connect a separate client and
/// leak all-but-one, or dispose a client another thread is mid-connect on. The S7 and
/// AB-CIP drivers serialize device access the same way; single-connection-per-PLC is
/// also what docs/v2/driver-specs.md recommends.
/// </summary>
private async Task<ITwinCATClient> EnsureConnectedAsync(DeviceState device, CancellationToken ct)
{
if (device.Client is { IsConnected: true } c) return c;
device.Client ??= _clientFactory.Create();
// Fast path — already connected, no gate needed.
if (device.Client is { IsConnected: true } fast) return fast;
await device.ConnectGate.WaitAsync(ct).ConfigureAwait(false);
try
{
await device.Client.ConnectAsync(device.ParsedAddress, _options.Timeout, ct)
.ConfigureAwait(false);
// Re-check under the gate: another caller may have connected while we waited.
if (device.Client is { IsConnected: true } c) return c;
// Discard a stale (created-but-disconnected) client before making a fresh one.
if (device.Client is { IsConnected: false } stale)
{
try { stale.Dispose(); } catch { /* best-effort */ }
device.Client = null;
}
var client = _clientFactory.Create();
client.OnSymbolVersionChanged += HandleSymbolVersionChanged;
try
{
await client.ConnectAsync(device.ParsedAddress, _options.Timeout, ct)
.ConfigureAwait(false);
}
catch
{
client.OnSymbolVersionChanged -= HandleSymbolVersionChanged;
client.Dispose();
throw;
}
device.Client = client;
return client;
}
catch
finally
{
device.Client.Dispose();
device.Client = null;
throw;
device.ConnectGate.Release();
}
return device.Client;
}
/// <summary>
/// Routes a wire-detected ADS symbol-version-changed (0x0702) to Core as an
/// <see cref="IRediscoverable"/> invocation (Driver.TwinCAT-013). A PLC re-download
/// invalidates every symbol + notification handle, so the address space must be rebuilt
/// — this is the documented TwinCAT failure mode, not a transient connection error.
/// </summary>
private void HandleSymbolVersionChanged(object? sender, EventArgs e) =>
OnRediscoveryNeeded?.Invoke(this, new RediscoveryEventArgs(
"TwinCAT symbol-version-changed 0x0702 — PLC program re-downloaded", ScopeHint: "TwinCAT"));
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);
@@ -437,6 +491,10 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
public TwinCATDeviceOptions Options { get; } = options;
public ITwinCATClient? Client { get; set; }
/// <summary>Serializes connect / reconnect so concurrent callers never race a client
/// create-or-dispose for this device (Driver.TwinCAT-007).</summary>
public SemaphoreSlim ConnectGate { get; } = new(1, 1);
public object ProbeLock { get; } = new();
public HostState HostState { get; set; } = HostState.Unknown;
public DateTime HostStateChangedUtc { get; set; } = DateTime.UtcNow;
@@ -447,5 +505,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
Client?.Dispose();
Client = null;
}
public void DisposeGate() => ConnectGate.Dispose();
}
}

View File

@@ -22,13 +22,25 @@ public static class TwinCATDriverFactoryExtensions
internal static TwinCATDriver CreateInstance(string driverInstanceId, string driverConfigJson)
{
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
var options = ParseOptions(driverConfigJson, driverInstanceId);
return new TwinCATDriver(options, driverInstanceId);
}
/// <summary>
/// Parse a TwinCAT driver-config JSON document into a <see cref="TwinCATDriverOptions"/>.
/// Shared by <see cref="CreateInstance"/> (constructor-time) and
/// <see cref="TwinCATDriver.InitializeAsync"/> / <see cref="TwinCATDriver.ReinitializeAsync"/>
/// so a config generation pushed via Reinitialize is actually applied (Driver.TwinCAT-001).
/// </summary>
internal static TwinCATDriverOptions ParseOptions(string driverConfigJson, string driverInstanceId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
var dto = JsonSerializer.Deserialize<TwinCATDriverConfigDto>(driverConfigJson, JsonOptions)
?? throw new InvalidOperationException(
$"TwinCAT driver config for '{driverInstanceId}' deserialised to null");
var options = new TwinCATDriverOptions
return new TwinCATDriverOptions
{
Devices = dto.Devices is { Count: > 0 }
? [.. dto.Devices.Select(d => new TwinCATDeviceOptions(
@@ -49,8 +61,6 @@ public static class TwinCATDriverFactoryExtensions
UseNativeNotifications = dto.UseNativeNotifications ?? true,
EnableControllerBrowse = dto.EnableControllerBrowse ?? false,
};
return new TwinCATDriver(options, driverInstanceId);
}
private static TwinCATTagDefinition BuildTag(TwinCATTagDto t, string driverInstanceId) =>

View File

@@ -9,6 +9,19 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
public static class TwinCATStatusMapper
{
public const uint Good = 0u;
/// <summary>
/// ADS <c>ADSERR_DEVICE_SYMBOLVERSIONINVALID</c> — error <c>0x0702</c> (1794 decimal).
/// Raised by the runtime after a PLC program re-download: every symbol handle and
/// notification handle the driver holds is now stale. The driver treats this as an
/// <see cref="Core.Abstractions.IRediscoverable"/> trigger, not a connection error
/// (docs/v2/driver-specs.md §6, Driver.TwinCAT-013).
/// </summary>
public const uint AdsSymbolVersionChanged = 0x0702u;
/// <summary>True when <paramref name="adsError"/> is the symbol-version-changed code.</summary>
public static bool IsSymbolVersionChanged(uint adsError) => adsError == AdsSymbolVersionChanged;
public const uint BadInternalError = 0x80020000u;
public const uint BadNodeIdUnknown = 0x80340000u;
public const uint BadNotWritable = 0x803B0000u;