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:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user