- 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>
265 lines
12 KiB
C#
265 lines
12 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|