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; /// /// Review 07 H13 + the Lane A runtime contract: the device and tag tables are build-and-swapped (never cleared /// in place under a reader), Dispose may run concurrently with a still-running ShutdownAsync, and /// an abandoned InitializeAsync that overlaps the next ShutdownAsync must not resurrect state. /// [Trait("Category", "Unit")] public sealed class AbLegacyLifecycleConcurrencyTests { private const string Host = "ab://10.0.0.5/1,0"; /// A probe tag whose read parks until released, ignoring cancellation (a wedged device call). 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 condition, string what) { var deadline = DateTime.UtcNow.AddSeconds(10); while (!condition()) { if (DateTime.UtcNow > deadline) throw new TimeoutException(what); await Task.Delay(10); } } /// /// 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. /// [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(); 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); } /// A re-init publishes a NEW device state; the old one is retired, not mutated in place. [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); } /// /// 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). /// [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); } } /// /// 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. /// [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); } /// /// 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). /// [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(); 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(() => 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); } }