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
@@ -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();
}
}