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

@@ -0,0 +1,194 @@
using System.Text.Json;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
/// <summary>
/// Regression coverage for the five High code-review findings closed 2026-05-22:
/// Driver.TwinCAT-001 (driverConfigJson ignored), -002 (LInt/ULInt narrowed to Int32),
/// -007 (EnsureConnectedAsync not thread-safe), -008 (ADS callbacks on the router thread),
/// -013 (no IRediscoverable / symbol-version-changed handling).
/// </summary>
[Trait("Category", "Unit")]
public sealed class TwinCATHighFindingsRegressionTests
{
private const string DeviceA = "ads://5.23.91.23.1.1:851";
// ---- Driver.TwinCAT-001 — Reinitialize applies the new config generation ----
[Fact]
public async Task ReinitializeAsync_applies_changed_device_config()
{
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(DeviceA)],
Probe = new TwinCATProbeOptions { Enabled = false },
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
drv.DeviceCount.ShouldBe(1);
// A config generation that adds a second device must be picked up at runtime.
var newConfig = JsonSerializer.Serialize(new
{
probe = new { enabled = false },
devices = new[]
{
new { hostAddress = DeviceA, deviceName = "Machine1" },
new { hostAddress = "ads://10.0.0.1.1.1:852", deviceName = "Machine2" },
},
});
await drv.ReinitializeAsync(newConfig, CancellationToken.None);
drv.DeviceCount.ShouldBe(2);
drv.GetDeviceState("ads://10.0.0.1.1.1:852")!.Options.DeviceName.ShouldBe("Machine2");
}
[Fact]
public async Task InitializeAsync_applies_supplied_config_over_constructor_options()
{
// Constructor seeds an empty option set; the JSON document is the authoritative config.
var drv = new TwinCATDriver(new TwinCATDriverOptions(), "drv-1");
var config = JsonSerializer.Serialize(new
{
probe = new { enabled = false },
devices = new[] { new { hostAddress = DeviceA } },
});
await drv.InitializeAsync(config, CancellationToken.None);
drv.DeviceCount.ShouldBe(1);
drv.GetDeviceState(DeviceA).ShouldNotBeNull();
}
// ---- Driver.TwinCAT-002 — 64-bit + unsigned types map without truncation ----
[Fact]
public void DataType_mapping_preserves_width_and_signedness()
{
TwinCATDataType.LInt.ToDriverDataType().ShouldBe(DriverDataType.Int64);
TwinCATDataType.ULInt.ToDriverDataType().ShouldBe(DriverDataType.UInt64);
TwinCATDataType.UDInt.ToDriverDataType().ShouldBe(DriverDataType.UInt32);
TwinCATDataType.DInt.ToDriverDataType().ShouldBe(DriverDataType.Int32);
TwinCATDataType.UInt.ToDriverDataType().ShouldBe(DriverDataType.UInt16);
TwinCATDataType.Int.ToDriverDataType().ShouldBe(DriverDataType.Int16);
TwinCATDataType.USInt.ToDriverDataType().ShouldBe(DriverDataType.UInt16);
TwinCATDataType.SInt.ToDriverDataType().ShouldBe(DriverDataType.Int16);
}
[Fact]
public async Task LInt_read_round_trips_value_above_int_MaxValue()
{
var factory = new FakeTwinCATClientFactory();
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(DeviceA)],
Tags = [new TwinCATTagDefinition("Big", DeviceA, "GVL.Big", TwinCATDataType.LInt)],
Probe = new TwinCATProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
long big = (long)int.MaxValue + 1_000;
factory.Customise = () => new FakeTwinCATClient { Values = { ["GVL.Big"] = big } };
var snapshot = (await drv.ReadAsync(["Big"], CancellationToken.None)).Single();
snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.Good);
snapshot.Value.ShouldBe(big); // no truncation into a 32-bit node
}
// ---- Driver.TwinCAT-007 — concurrent EnsureConnectedAsync creates exactly one client ----
[Fact]
public async Task Concurrent_reads_on_one_device_create_a_single_client()
{
var factory = new FakeTwinCATClientFactory();
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(DeviceA)],
Tags = [new TwinCATTagDefinition("X", DeviceA, "GVL.X", TwinCATDataType.DInt)],
Probe = new TwinCATProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = () => new FakeTwinCATClient { Values = { ["GVL.X"] = 7 } };
// 32 readers race the lazy connect. Without the per-device gate this leaks clients.
var tasks = Enumerable.Range(0, 32)
.Select(_ => Task.Run(() => drv.ReadAsync(["X"], CancellationToken.None)));
await Task.WhenAll(tasks);
factory.Clients.Count.ShouldBe(1);
factory.Clients[0].ConnectCount.ShouldBe(1);
}
[Fact]
public async Task Concurrent_reads_and_writes_share_one_client()
{
var factory = new FakeTwinCATClientFactory();
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(DeviceA)],
Tags = [new TwinCATTagDefinition("X", DeviceA, "GVL.X", TwinCATDataType.DInt)],
Probe = new TwinCATProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = () => new FakeTwinCATClient { Values = { ["GVL.X"] = 1 } };
var work = new List<Task>();
for (var i = 0; i < 16; i++)
{
work.Add(Task.Run(() => drv.ReadAsync(["X"], CancellationToken.None)));
work.Add(Task.Run(() => drv.WriteAsync(
[new WriteRequest("X", 5)], CancellationToken.None)));
}
await Task.WhenAll(work);
factory.Clients.Count.ShouldBe(1);
}
// ---- Driver.TwinCAT-013 — symbol-version-changed routes to IRediscoverable ----
[Fact]
public void TwinCATDriver_implements_IRediscoverable()
{
var drv = new TwinCATDriver(new TwinCATDriverOptions(), "drv-1");
drv.ShouldBeAssignableTo<IRediscoverable>();
}
[Fact]
public async Task Symbol_version_changed_raises_OnRediscoveryNeeded()
{
var factory = new FakeTwinCATClientFactory();
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(DeviceA)],
Tags = [new TwinCATTagDefinition("X", DeviceA, "GVL.X", TwinCATDataType.DInt)],
Probe = new TwinCATProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
RediscoveryEventArgs? raised = null;
drv.OnRediscoveryNeeded += (_, args) => raised = args;
// Force a connect so the driver wires its handler onto the client.
await drv.ReadAsync(["X"], CancellationToken.None);
factory.Clients.ShouldHaveSingleItem();
// The client observed ADS 0x0702 on the wire and signalled it.
factory.Clients[0].FireSymbolVersionChanged();
raised.ShouldNotBeNull();
raised!.Reason.ShouldContain("0x0702");
}
[Fact]
public void StatusMapper_recognises_symbol_version_changed_code()
{
TwinCATStatusMapper.AdsSymbolVersionChanged.ShouldBe(0x0702u);
TwinCATStatusMapper.IsSymbolVersionChanged(0x0702u).ShouldBeTrue();
TwinCATStatusMapper.IsSymbolVersionChanged(0u).ShouldBeFalse();
}
}