Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasDeviceTableLifecycleTests.cs
T
Joseph Doherty a32894e93b fix(focas): build-and-swap the device table; make the lifecycle overlap-safe (R07:H13)
_devices was a plain Dictionary cleared and refilled in place by InitializeAsync /
ShutdownAsync while the retained poll engine, the fixed-tree and alarm loops and the
actor's health tick enumerated it ("Collection was modified"). It is now built whole and
published by reference swap, and a release swaps it out to empty before tearing it down.

Lane A's runtime overlaps lifecycle calls, so the driver now tolerates both shapes:
- a lifecycle generation is bumped by every Initialize/Shutdown; an initialise that finds
  itself superseded (abandoned by the runtime, then a shutdown ran) publishes nothing,
  starts no loops, writes no health and throws OperationCanceledException;
- a shutdown overlapping another (dispose beside a still-running shutdown) waits for the
  first's teardown via a release tail instead of returning on an empty table;
- released devices are Retired: EnsureConnectedAsync refuses to reconnect them, and a
  connect that completes after retirement disposes what it opened. Clients are disposed
  under the device ConnectGate (bounded).

Refs #740
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 14:03:08 -04:00

201 lines
9.1 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// #740 / R07:H13 — the device table is read by the retained poll engine, the fixed-tree loop, the alarm
/// loop and the actor's health tick, so a re-initialise must build a fresh table and swap it in rather than
/// clearing and refilling the live one. Also pins the lifecycle overlaps the runtime produces since Lane A:
/// a dispose running beside a still-running shutdown, and an abandoned initialise overlapping the next
/// attempt's shutdown.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasDeviceTableLifecycleTests
{
private const string Host = "focas://10.0.0.5:8193";
private static FocasDriverOptions Options(bool probe = false, bool fixedTree = false) => new()
{
Devices = [new FocasDeviceOptions(Host)],
RawTags = [FocasRawTags.Entry(new FocasTagDefinition("raw/R", Host, "R100", FocasDataType.Byte))],
Probe = new FocasProbeOptions { Enabled = probe, Interval = TimeSpan.FromMilliseconds(20) },
FixedTree = new FocasFixedTreeOptions { Enabled = fixedTree, PollInterval = TimeSpan.FromMilliseconds(10) },
};
/// <summary>A re-initialise publishes a NEW table; a reader holding the old one never sees it emptied.</summary>
[Fact]
public async Task Reinitialize_swaps_in_a_new_table_and_never_mutates_the_one_a_reader_holds()
{
var drv = new FocasDriver(Options(), "focas-swap", new FakeFocasClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
var before = drv.DeviceTable;
before.Count.ShouldBe(1);
await drv.InitializeAsync("{}", CancellationToken.None);
drv.DeviceTable.ShouldNotBeSameAs(before);
drv.DeviceTable.Count.ShouldBe(1);
before.Count.ShouldBe(1, "the table a concurrent reader enumerates must not be cleared under it");
await drv.ShutdownAsync(CancellationToken.None);
before.Count.ShouldBe(1);
drv.DeviceTable.Count.ShouldBe(0);
}
/// <summary>Hammering every table reader during repeated re-initialises throws nothing — and reads still succeed.</summary>
[Fact]
public async Task Readers_survive_repeated_reinitialise()
{
var factory = new FakeFocasClientFactory();
var drv = new FocasDriver(Options(fixedTree: true), "focas-hammer", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
using var stop = new CancellationTokenSource();
var good = 0;
var errors = new List<Exception>();
var reader = Task.Run(async () =>
{
while (!stop.IsCancellationRequested)
{
try
{
_ = drv.GetHostStatuses();
var snaps = await drv.ReadAsync(["raw/R", $"{Host}/Identity/SeriesNumber"], CancellationToken.None);
if (snaps[0].StatusCode == FocasStatusMapper.Good) Interlocked.Increment(ref good);
}
catch (Exception ex) { lock (errors) errors.Add(ex); }
}
});
for (var i = 0; i < 100; i++)
await drv.InitializeAsync("{}", CancellationToken.None);
await Task.Delay(100);
stop.Cancel();
await reader;
await drv.ShutdownAsync(CancellationToken.None);
errors.ShouldBeEmpty();
good.ShouldBeGreaterThan(0, "positive control: the reader really did read through the table");
}
/// <summary>A client whose connect parks until released and ignores cancellation.</summary>
private sealed class ParkedConnectClient(TaskCompletionSource entered, Task release) : FakeFocasClient
{
public override async Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken ct)
{
entered.TrySetResult();
await release.ConfigureAwait(false);
await base.ConnectAsync(address, timeout, CancellationToken.None).ConfigureAwait(false);
}
}
/// <summary>
/// A data-path read parked in connect when shutdown runs must not leave a live client on the released
/// device: the device is retired, so the connect that completes afterwards disposes what it opened.
/// </summary>
[Fact]
public async Task Connect_completing_after_shutdown_does_not_leave_a_live_client()
{
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var factory = new FakeFocasClientFactory { Customise = () => new ParkedConnectClient(entered, release.Task) };
var drv = new FocasDriver(Options(), "focas-retire", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var read = drv.ReadAsync(["raw/R"], CancellationToken.None);
await entered.Task.WaitAsync(TimeSpan.FromSeconds(5));
var shutdown = drv.ShutdownAsync(CancellationToken.None);
await Task.Delay(50);
release.SetResult();
await shutdown.WaitAsync(TimeSpan.FromSeconds(10));
var snaps = await read.WaitAsync(TimeSpan.FromSeconds(5));
factory.Clients.Count.ShouldBe(1);
factory.Clients[0].IsConnected.ShouldBeFalse("a connect finishing on a retired device must dispose its client");
factory.Clients[0].DisposeCount.ShouldBeGreaterThanOrEqualTo(1);
snaps[0].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
}
/// <summary>
/// An initialise abandoned mid-way (the runtime timed it out and moved on to a shutdown) must not
/// resurrect the driver when it finally completes: no device table, no loops, no connection.
/// </summary>
[Fact]
public async Task Abandoned_initialise_finishing_after_shutdown_starts_nothing()
{
var factory = new FakeFocasClientFactory();
var options = Options(probe: true);
using var gate = new ManualResetEventSlim(false);
var inRebind = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var drv = new FocasDriver(options, "focas-abandoned", factory, rebind: _ =>
{
inRebind.TrySetResult();
gate.Wait(TimeSpan.FromSeconds(10));
return new FocasDriverBinding(options, factory);
});
var init = Task.Run(() => drv.InitializeAsync("{\"devices\":[]}", CancellationToken.None));
await inRebind.Task.WaitAsync(TimeSpan.FromSeconds(5));
await drv.ShutdownAsync(CancellationToken.None);
gate.Set();
await Should.ThrowAsync<OperationCanceledException>(() => init.WaitAsync(TimeSpan.FromSeconds(10)));
await Task.Delay(200); // several probe intervals — a started loop would have connected by now
drv.DeviceCount.ShouldBe(0);
factory.Clients.ShouldBeEmpty();
drv.GetHealth().State.ShouldNotBe(DriverState.Healthy);
// Positive control: the same driver, initialised without being superseded, does start its probe.
await drv.InitializeAsync("{\"devices\":[]}", CancellationToken.None);
await WaitUntil(() => factory.Clients.Count > 0);
drv.DeviceCount.ShouldBe(1);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
await drv.DisposeAsync();
}
/// <summary>A loop wedged in a read that ignores cancellation for a while.</summary>
private sealed class SlowProbeClient : FakeFocasClient
{
public override async Task<bool> ProbeAsync(CancellationToken ct)
{
await Task.Delay(400, CancellationToken.None).ConfigureAwait(false);
return true;
}
}
/// <summary>
/// Lane A disposes while its own shutdown may still be running. The dispose must not return before the
/// teardown it overlaps has released the client — it waits for it rather than finding an empty table.
/// </summary>
[Fact]
public async Task Dispose_concurrent_with_shutdown_waits_for_the_teardown()
{
var factory = new FakeFocasClientFactory { Customise = () => new SlowProbeClient() };
var drv = new FocasDriver(Options(probe: true), "focas-dispose", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitUntil(() => factory.Clients.Count > 0 && factory.Clients[0].IsConnected);
var shutdown = drv.ShutdownAsync(CancellationToken.None);
var dispose = drv.DisposeAsync().AsTask();
await dispose.WaitAsync(TimeSpan.FromSeconds(10));
factory.Clients.ShouldAllBe(c => !c.IsConnected);
factory.Clients.ShouldAllBe(c => c.DisposeCount >= 1);
await shutdown.WaitAsync(TimeSpan.FromSeconds(10));
drv.DeviceCount.ShouldBe(0);
}
private static async Task WaitUntil(Func<bool> condition)
{
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
while (!condition())
{
if (DateTime.UtcNow > deadline) throw new TimeoutException("condition not reached");
await Task.Delay(10);
}
}
}