fix(driver-twincat): resolve Low code-review findings (Driver.TwinCAT-004,006,014,015,016)
- Driver.TwinCAT-004: corrected the IEC time-type inline comments; documented that the driver currently surfaces them as raw UInt32 counters. - Driver.TwinCAT-006: ResolveHost returns a documented UnresolvedHost sentinel when no devices are configured instead of returning the logical DriverInstanceId (which never matches GetHostStatuses). - Driver.TwinCAT-014: wired Probe.Timeout into the probe-loop call and added a NotificationMaxDelayMs config knob threaded through AddNotificationAsync. - Driver.TwinCAT-015: Dispose() runs a genuinely synchronous teardown with bounded waits (no sync-over-async deadlock pattern). - Driver.TwinCAT-016: pinned the Structure-tag rejection and the probe-loop vs read disposal race with regression tests. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
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 remaining (Low/Medium) code-review findings:
|
||||
/// Driver.TwinCAT-004 (IEC time-type doc-comment accuracy), -006 (ResolveHost
|
||||
/// sentinel for no-devices fallback), -014 (NotificationMaxDelayMs config knob
|
||||
/// and ProbeOptions.Timeout wiring), -015 (Dispose runs a true synchronous
|
||||
/// teardown, no sync-over-async), -016 (gap-fill tests: Structure-tag rejection,
|
||||
/// concurrent probe + read race).
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class TwinCATLowFindingsRegressionTests
|
||||
{
|
||||
private const string DeviceA = "ads://5.23.91.23.1.1:851";
|
||||
|
||||
// ---- Driver.TwinCAT-004 — TIME/DATE/DT/TOD surface unchanged but comments corrected ----
|
||||
|
||||
[Fact]
|
||||
public void Iec_time_types_map_to_uint32_raw_counter()
|
||||
{
|
||||
// Documents the contract called out in the corrected comments: TIME / DATE / DT / TOD
|
||||
// surface as their raw UDINT counter (32-bit unsigned), not as decoded DateTime/TimeSpan.
|
||||
// The next implementer who wants to decode them needs to see this mapping is intentional.
|
||||
TwinCATDataType.Time.ToDriverDataType().ShouldBe(DriverDataType.UInt32);
|
||||
TwinCATDataType.Date.ToDriverDataType().ShouldBe(DriverDataType.UInt32);
|
||||
TwinCATDataType.DateTime.ToDriverDataType().ShouldBe(DriverDataType.UInt32);
|
||||
TwinCATDataType.TimeOfDay.ToDriverDataType().ShouldBe(DriverDataType.UInt32);
|
||||
}
|
||||
|
||||
// ---- Driver.TwinCAT-006 — ResolveHost sentinel when no devices are configured ----
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveHost_returns_unresolved_sentinel_when_no_devices()
|
||||
{
|
||||
// DriverInstanceId is a logical config-DB key, not a host address; consumers expect a
|
||||
// host key that correlates with GetHostStatuses(). When there are no devices and the
|
||||
// reference is unknown, ResolveHost must return the documented unresolved sentinel
|
||||
// (empty string), not the driver-instance ID.
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions(), "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.ResolveHost("anything").ShouldBe(string.Empty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveHost_unresolved_sentinel_matches_no_GetHostStatuses_entry()
|
||||
{
|
||||
// Documents the contract: the sentinel should never match a real connectivity-status row.
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions(), "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var sentinel = drv.ResolveHost("anything");
|
||||
drv.GetHostStatuses().ShouldNotContain(s => s.HostName == sentinel);
|
||||
}
|
||||
|
||||
// ---- Driver.TwinCAT-014 — config surface knobs are honoured ----
|
||||
|
||||
[Fact]
|
||||
public async Task ProbeOptions_Timeout_is_applied_to_probe_calls()
|
||||
{
|
||||
// The previous implementation declared a Timeout field but never read it — the probe
|
||||
// path connected with _options.Timeout. The probe must use its own configured timeout.
|
||||
var observed = new List<TimeSpan>();
|
||||
var factory = new FakeTwinCATClientFactory
|
||||
{
|
||||
Customise = () => new ProbeTimeoutCapturingFake(observed),
|
||||
};
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions(DeviceA)],
|
||||
Probe = new TwinCATProbeOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Interval = TimeSpan.FromMilliseconds(100),
|
||||
Timeout = TimeSpan.FromMilliseconds(750), // distinct from the connect timeout
|
||||
},
|
||||
Timeout = TimeSpan.FromMilliseconds(2_000),
|
||||
}, "drv-1", factory);
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await WaitForAsync(() => observed.Count >= 1, TimeSpan.FromSeconds(2));
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
|
||||
observed.ShouldContain(TimeSpan.FromMilliseconds(750));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotificationMaxDelayMs_is_exposed_on_driver_options()
|
||||
{
|
||||
// The driver-spec lists NotificationMaxDelayMs as a per-device knob; the implementation
|
||||
// previously hard-coded 0 in NotificationSettings. Expose a configurable field so
|
||||
// operators can batch low-priority notifications.
|
||||
var options = new TwinCATDriverOptions { NotificationMaxDelayMs = 200 };
|
||||
options.NotificationMaxDelayMs.ShouldBe(200);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotificationMaxDelayMs_parses_from_driver_config_json()
|
||||
{
|
||||
var json = JsonSerializer.Serialize(new
|
||||
{
|
||||
devices = new[] { new { hostAddress = DeviceA } },
|
||||
notificationMaxDelayMs = 150,
|
||||
});
|
||||
var parsed = TwinCATDriverFactoryExtensions.ParseOptionsForTests(json, "drv-1");
|
||||
parsed.NotificationMaxDelayMs.ShouldBe(150);
|
||||
}
|
||||
|
||||
// ---- Driver.TwinCAT-015 — Dispose runs a true synchronous teardown ----
|
||||
|
||||
[Fact]
|
||||
public void Dispose_does_not_block_on_async_in_default_synchronization_context()
|
||||
{
|
||||
// Sync-over-async on a single-threaded sync context (like the OPC UA stack thread) can
|
||||
// deadlock. Dispose() must complete without scheduling continuations through a captured
|
||||
// sync context. We verify by running Dispose() inside a SynchronizationContext that
|
||||
// would deadlock a sync-over-async teardown.
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions(DeviceA)],
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
}, "drv-1");
|
||||
drv.InitializeAsync("{}", CancellationToken.None).GetAwaiter().GetResult();
|
||||
|
||||
var ctx = new SingleThreadedSyncContext();
|
||||
var prev = SynchronizationContext.Current;
|
||||
Exception? captured = null;
|
||||
try
|
||||
{
|
||||
SynchronizationContext.SetSynchronizationContext(ctx);
|
||||
// If Dispose schedules its continuations through the captured context (sync-over-
|
||||
// async pattern), and the context is single-threaded with nothing pumping, this
|
||||
// will hang. We give it 5 seconds — well above any reasonable sync teardown.
|
||||
var thread = new Thread(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
SynchronizationContext.SetSynchronizationContext(ctx);
|
||||
drv.Dispose();
|
||||
}
|
||||
catch (Exception ex) { captured = ex; }
|
||||
}) { IsBackground = true };
|
||||
thread.Start();
|
||||
thread.Join(TimeSpan.FromSeconds(5)).ShouldBeTrue(
|
||||
"Dispose() did not complete within 5s — likely sync-over-async deadlock " +
|
||||
"(Driver.TwinCAT-015).");
|
||||
}
|
||||
finally
|
||||
{
|
||||
SynchronizationContext.SetSynchronizationContext(prev);
|
||||
}
|
||||
captured.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single-threaded sync context that posts continuations to an internal queue but never
|
||||
/// pumps them. Any sync-over-async code that captures this context and waits for a
|
||||
/// continuation will deadlock — exactly the OPC UA stack thread scenario.
|
||||
/// </summary>
|
||||
private sealed class SingleThreadedSyncContext : SynchronizationContext
|
||||
{
|
||||
private readonly System.Collections.Concurrent.ConcurrentQueue<(SendOrPostCallback cb, object? state)> _queue = new();
|
||||
public override void Post(SendOrPostCallback d, object? state) => _queue.Enqueue((d, state));
|
||||
public override void Send(SendOrPostCallback d, object? state) => d(state);
|
||||
}
|
||||
|
||||
// ---- Driver.TwinCAT-016 — gap-fill tests for previously closed findings ----
|
||||
|
||||
[Fact]
|
||||
public void Structure_typed_pre_declared_tag_is_rejected_at_config_parse()
|
||||
{
|
||||
// Driver.TwinCAT-003 — config-time rejection. A Structure tag must fail loudly with a
|
||||
// clear error rather than reading as a garbage int blob or failing late on a write.
|
||||
var json = JsonSerializer.Serialize(new
|
||||
{
|
||||
devices = new[] { new { hostAddress = DeviceA } },
|
||||
tags = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
name = "Udt1",
|
||||
deviceHostAddress = DeviceA,
|
||||
symbolPath = "MAIN.fbInstance",
|
||||
dataType = "Structure",
|
||||
},
|
||||
},
|
||||
});
|
||||
var ex = Should.Throw<InvalidOperationException>(() =>
|
||||
TwinCATDriverFactoryExtensions.ParseOptionsForTests(json, "drv-1"));
|
||||
ex.Message.ShouldContain("Structure");
|
||||
ex.Message.ShouldContain("Udt1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_loop_and_read_share_one_client_per_device()
|
||||
{
|
||||
// Driver.TwinCAT-007 / -009 — gap-fill: race the probe loop against concurrent reads on
|
||||
// the same device. The per-device gate must serialize connect; the probe-task await on
|
||||
// ShutdownAsync must let the loop exit cleanly. Without these, the test trips a leaked
|
||||
// client or a disposal race.
|
||||
var factory = new FakeTwinCATClientFactory
|
||||
{
|
||||
Customise = () => new FakeTwinCATClient { Values = { ["GVL.X"] = 1 }, ProbeResult = true },
|
||||
};
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions(DeviceA)],
|
||||
Tags = [new TwinCATTagDefinition("X", DeviceA, "GVL.X", TwinCATDataType.DInt)],
|
||||
Probe = new TwinCATProbeOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Interval = TimeSpan.FromMilliseconds(20),
|
||||
Timeout = TimeSpan.FromMilliseconds(50),
|
||||
},
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
// Race 64 readers against the probe loop for ~500ms.
|
||||
var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
|
||||
var work = Enumerable.Range(0, 64).Select(_ => Task.Run(async () =>
|
||||
{
|
||||
while (!cts.IsCancellationRequested)
|
||||
{
|
||||
try { await drv.ReadAsync(["X"], CancellationToken.None); }
|
||||
catch { /* shutdown-races on the very last call are ok */ }
|
||||
}
|
||||
})).ToArray();
|
||||
await Task.WhenAll(work);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
|
||||
// One client total. If the gate is broken, concurrent connects leak additional clients.
|
||||
factory.Clients.Count.ShouldBe(1);
|
||||
factory.Clients[0].ConnectCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
private static async Task WaitForAsync(Func<bool> condition, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (!condition() && DateTime.UtcNow < deadline)
|
||||
await Task.Delay(20);
|
||||
}
|
||||
|
||||
/// <summary>Captures the timeout argument from ProbeAsync invocations (via the connect path).</summary>
|
||||
private sealed class ProbeTimeoutCapturingFake : FakeTwinCATClient
|
||||
{
|
||||
private readonly List<TimeSpan> _observed;
|
||||
public ProbeTimeoutCapturingFake(List<TimeSpan> observed) { _observed = observed; }
|
||||
|
||||
public override Task ConnectAsync(TwinCATAmsAddress address, TimeSpan timeout, CancellationToken ct)
|
||||
{
|
||||
// The driver calls EnsureConnectedAsync with the probe timeout for probe-initiated
|
||||
// connects. The probe timeout is distinct from the driver-level Timeout; we record
|
||||
// it on the first connect.
|
||||
lock (_observed) _observed.Add(timeout);
|
||||
return base.ConnectAsync(address, timeout, ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user