Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyLifecycleConcurrencyTests.cs
T

256 lines
12 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
/// <summary>
/// Review 07 H13 + the Lane A runtime contract: the device and tag tables are build-and-swapped (never cleared
/// in place under a reader), <c>Dispose</c> may run concurrently with a still-running <c>ShutdownAsync</c>, and
/// an abandoned <c>InitializeAsync</c> that overlaps the next <c>ShutdownAsync</c> must not resurrect state.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbLegacyLifecycleConcurrencyTests
{
private const string Host = "ab://10.0.0.5/1,0";
/// <summary>A probe tag whose read parks until released, ignoring cancellation (a wedged device call).</summary>
private sealed class ParkingProbeTag(AbLegacyTagCreateParams p, Task release) : FakeAbLegacyTag(p)
{
public readonly TaskCompletionSource Entered = new(TaskCreationOptions.RunContinuationsAsynchronously);
public override async Task ReadAsync(CancellationToken ct)
{
Entered.TrySetResult();
await release.ConfigureAwait(false);
await base.ReadAsync(ct).ConfigureAwait(false);
}
}
private static async Task WaitUntilAsync(Func<bool> condition, string what)
{
var deadline = DateTime.UtcNow.AddSeconds(10);
while (!condition())
{
if (DateTime.UtcNow > deadline) throw new TimeoutException(what);
await Task.Delay(10);
}
}
/// <summary>
/// Re-initialising in a loop while other threads read the tables never throws "Collection was modified":
/// the readers see either the old or the new table, never one being cleared.
/// </summary>
[Fact]
public async Task Reinit_under_concurrent_readers_never_throws()
{
var factory = new FakeAbLegacyTagFactory { Customise = p => new FakeAbLegacyTag(p) { Value = 1 } };
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions(Host), new AbLegacyDeviceOptions("ab://10.0.0.6/1,0")],
RawTags = AbLegacyRawTags.Entries(
new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int),
new AbLegacyTagDefinition("Y", "ab://10.0.0.6/1,0", "N7:1", AbLegacyDataType.Int)),
Probe = new AbLegacyProbeOptions { Enabled = false },
}, "drv-swap", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
using var stop = new CancellationTokenSource();
var failures = new System.Collections.Concurrent.ConcurrentQueue<Exception>();
var reads = 0;
var readers = Enumerable.Range(0, 3).Select(n => Task.Run(async () =>
{
while (!stop.IsCancellationRequested)
{
try
{
_ = drv.GetHostStatuses();
_ = drv.ResolveHost("X");
_ = await drv.ReadAsync(["X", "Y"], CancellationToken.None);
Interlocked.Increment(ref reads);
}
catch (Exception ex) { failures.Enqueue(ex); }
}
})).ToArray();
// Keep re-initialising until the readers have demonstrably overlapped many swaps.
var deadline = DateTime.UtcNow.AddSeconds(10);
for (var i = 0; i < 100 || Volatile.Read(ref reads) < 200; i++)
{
if (DateTime.UtcNow > deadline) break;
await drv.InitializeAsync("{}", CancellationToken.None);
}
stop.Cancel();
await Task.WhenAll(readers);
failures.ShouldBeEmpty();
reads.ShouldBeGreaterThan(0); // positive control: the readers really ran against the swapping tables
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>A re-init publishes a NEW device state; the old one is retired, not mutated in place.</summary>
[Fact]
public async Task Reinit_publishes_a_new_table_and_retires_the_old_state()
{
var factory = new FakeAbLegacyTagFactory { Customise = p => new FakeAbLegacyTag(p) { Value = 1 } };
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions(Host)],
RawTags = AbLegacyRawTags.Entries(new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int)),
Probe = new AbLegacyProbeOptions { Enabled = false },
}, "drv-swap", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
(await drv.ReadAsync(["X"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
var old = drv.GetDeviceState(Host)!;
var oldTag = factory.Tags["N7:0"];
await drv.InitializeAsync("{}", CancellationToken.None);
var fresh = drv.GetDeviceState(Host)!;
fresh.ShouldNotBeSameAs(old);
old.IsRetired.ShouldBeTrue();
oldTag.Disposed.ShouldBeTrue();
(await drv.ReadAsync(["X"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>
/// A runtime created by a read that was already in flight when shutdown retired its device state is disposed
/// instead of being cached on the retired state (where nothing would ever release it).
/// </summary>
[Fact]
public async Task Runtime_created_after_retirement_is_disposed_not_cached()
{
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
FakeAbLegacyTag? created = null;
var factory = new FakeAbLegacyTagFactory
{
Customise = p => created = new InitParkingTag(p, entered, release.Task) { Value = 1 },
};
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions(Host)],
RawTags = AbLegacyRawTags.Entries(new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int)),
Probe = new AbLegacyProbeOptions { Enabled = false },
}, "drv-late", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var state = drv.GetDeviceState(Host)!;
var read = drv.ReadAsync(["X"], CancellationToken.None);
await entered.Task;
await drv.ShutdownAsync(CancellationToken.None);
release.SetResult();
_ = await read;
created.ShouldNotBeNull();
created!.Disposed.ShouldBeTrue();
state.Runtimes.ShouldBeEmpty();
drv.GetHealth().State.ShouldBe(DriverState.Unknown); // the late read did not overwrite shutdown's health
}
private sealed class InitParkingTag(AbLegacyTagCreateParams p, TaskCompletionSource entered, Task release)
: FakeAbLegacyTag(p)
{
public override async Task InitializeAsync(CancellationToken ct)
{
entered.TrySetResult();
await release.ConfigureAwait(false);
await base.InitializeAsync(ct).ConfigureAwait(false);
}
}
/// <summary>
/// Lane A's final stop calls ShutdownAsync and then DisposeAsync/Dispose; under the 5 s bound Dispose can run
/// while ShutdownAsync is still joining a wedged probe loop. Both complete without throwing and every runtime
/// is released.
/// </summary>
[Fact]
public async Task Dispose_concurrent_with_running_shutdown_is_safe()
{
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
ParkingProbeTag? probe = null;
var factory = new FakeAbLegacyTagFactory
{
Customise = p => p.TagName == "S:0"
? probe = new ParkingProbeTag(p, release.Task)
: new FakeAbLegacyTag(p) { Value = 1 },
};
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions(Host)],
RawTags = AbLegacyRawTags.Entries(new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int)),
Probe = new AbLegacyProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(20) },
}, "drv-dispose", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
(await drv.ReadAsync(["X"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
await WaitUntilAsync(() => probe is not null, "probe never created");
await probe!.Entered.Task;
var shutdown = drv.ShutdownAsync(CancellationToken.None); // parks joining the wedged probe loop
await Task.Delay(50, TestContext.Current.CancellationToken);
shutdown.IsCompleted.ShouldBeFalse();
Should.NotThrow(() => drv.Dispose());
await Should.NotThrowAsync(async () => await drv.DisposeAsync());
release.SetResult();
await shutdown;
factory.Tags["N7:0"].Disposed.ShouldBeTrue();
probe.Disposed.ShouldBeTrue();
drv.DeviceCount.ShouldBe(0);
}
/// <summary>
/// An InitializeAsync the runtime abandoned (it is parked releasing a wedged prior probe loop) overlaps the next
/// ShutdownAsync. When it resumes it must NOT publish its tables or start probe loops — that state would sit
/// behind a driver the runtime believes is stopped. A later Initialize publishes normally (positive control).
/// </summary>
[Fact]
public async Task Abandoned_initialize_overlapping_shutdown_does_not_resurrect_state()
{
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var probes = new System.Collections.Concurrent.ConcurrentQueue<FakeAbLegacyTag>();
ParkingProbeTag? first = null;
var factory = new FakeAbLegacyTagFactory
{
Customise = p =>
{
FakeAbLegacyTag t = first is null ? first = new ParkingProbeTag(p, release.Task) : new FakeAbLegacyTag(p);
probes.Enqueue(t);
return t;
},
};
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions(Host, AbLegacyPlcFamily.Slc500)],
Probe = new AbLegacyProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(20) },
}, "drv-abandon", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitUntilAsync(() => first is not null, "probe never created");
await first!.Entered.Task;
var abandoned = drv.InitializeAsync("{}", CancellationToken.None); // parks joining the wedged loop
await Task.Delay(50, TestContext.Current.CancellationToken);
abandoned.IsCompleted.ShouldBeFalse();
await drv.ShutdownAsync(CancellationToken.None);
release.SetResult();
await Should.ThrowAsync<OperationCanceledException>(() => abandoned);
var createdAfter = probes.Count;
await Task.Delay(150, TestContext.Current.CancellationToken); // several probe intervals
drv.DeviceCount.ShouldBe(0);
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
probes.Count.ShouldBe(createdAfter);
// Positive control: a fresh Initialize after the stop publishes and probes as normal.
await drv.InitializeAsync("{}", CancellationToken.None);
drv.DeviceCount.ShouldBe(1);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
await WaitUntilAsync(() => probes.Count > createdAfter, "fresh init never probed");
await drv.ShutdownAsync(CancellationToken.None);
}
}