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:
Joseph Doherty
2026-05-23 08:17:42 -04:00
parent bccff1339d
commit 3c75db7eb6
11 changed files with 389 additions and 27 deletions

View File

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