using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests; /// /// R07:H5 — overlapping lifecycle calls. DriverInstanceActor dispatches initialize on a /// fire-and-forget task and a reconnect retry can start a second attempt while the first is still in /// OpenAsync; an abandoned initialize can also still be running when the next shutdown starts. /// Every wait here is a condition wait on the fake's own signals, never a sleep. /// [Trait("Category", "Unit")] public sealed class S7LifecycleGateTests { private static readonly TimeSpan Bound = TimeSpan.FromSeconds(10); private static S7DriverOptions Options(bool probe = false) => new() { Host = "192.0.2.1", Timeout = TimeSpan.FromSeconds(30), // the fake's gated open is released by the test, never timed out Probe = new S7ProbeOptions { Enabled = probe, Interval = TimeSpan.FromSeconds(30) }, RawTags = S7RawTags.Entries(new S7TagDefinition("W0", "DB1.DBW0", S7DataType.UInt16, Writable: false)), }; /// A factory whose FIRST open blocks until released; later opens complete immediately. private sealed class FirstOpenBlocks { public readonly ScriptedS7PlcFactory Factory = new(); public readonly TaskCompletionSource FirstOpenEntered = new(TaskCreationOptions.RunContinuationsAsynchronously); public readonly TaskCompletionSource ReleaseFirstOpen = new(TaskCreationOptions.RunContinuationsAsynchronously); public FirstOpenBlocks() { Factory.Open = async (index, ct) => { if (index != 0) return; FirstOpenEntered.TrySetResult(); await ReleaseFirstOpen.Task.WaitAsync(ct).ConfigureAwait(false); }; } } /// /// A second initialize waits for the first instead of running beside it, then releases the first /// attempt's connection: exactly one live connection remains. Before the gate, attempt B opened /// its own connection while A was still in OpenAsync, and A's published connection (and /// probe loop) was overwritten and never released. /// [Fact] public async Task Overlapping_initializes_are_serialised_and_leave_one_live_connection() { var ct = TestContext.Current.CancellationToken; var f = new FirstOpenBlocks(); using var drv = new S7Driver(Options(), "s7-overlap", f.Factory); var a = drv.InitializeAsync("{}", ct); await f.FirstOpenEntered.Task.WaitAsync(Bound, ct); var b = drv.InitializeAsync("{}", ct); // Positive control first: B is genuinely pending (not finished, not faulted)... b.IsCompleted.ShouldBeFalse(); // ...and while A holds the gate B has not created a connection of its own. B can only reach // its open after A completes, so this is not a race the test can lose by being slow. f.Factory.Created.Count.ShouldBe(1, "a second initialize must not open beside the first"); f.ReleaseFirstOpen.SetResult(); await a.WaitAsync(Bound, ct); await b.WaitAsync(Bound, ct); f.Factory.Created.Count.ShouldBe(2); f.Factory.Created[0].Disposed.ShouldBeTrue("B releases A's published connection before replacing it"); f.Factory.Created[1].Disposed.ShouldBeFalse(); drv.GetHealth().State.ShouldBe(DriverState.Healthy); (await drv.ReadAsync(["W0"], ct))[0].StatusCode.ShouldBe(0u); } /// /// An initialize still running when a shutdown gives up waiting for it must not publish its /// connection, its health or its probe loop over the shut-down driver; it fails and disposes what /// it opened. Before the fix the late attempt published Healthy + a live connection after the /// shutdown returned. /// [Fact] public async Task An_initialize_superseded_by_shutdown_publishes_nothing() { var ct = TestContext.Current.CancellationToken; var f = new FirstOpenBlocks(); using var drv = new S7Driver(Options(probe: true), "s7-superseded", f.Factory) { LifecycleGateTimeout = TimeSpan.FromSeconds(1), }; var init = drv.InitializeAsync("{}", ct); await f.FirstOpenEntered.Task.WaitAsync(Bound, ct); // The shutdown cannot get the lifecycle gate (the attempt holds it), gives up after its bound // and tears down anyway. await drv.ShutdownAsync(ct).WaitAsync(Bound, ct); drv.GetHealth().State.ShouldBe(DriverState.Unknown); // The abandoned attempt now completes its open... f.ReleaseFirstOpen.SetResult(); var ex = await Should.ThrowAsync(() => init.WaitAsync(Bound, ct)); ex.Message.ShouldContain("superseded"); // ...and published nothing: no connection kept, health untouched, the driver still shut down. f.Factory.Created.ShouldHaveSingleItem().Disposed.ShouldBeTrue(); drv.GetHealth().State.ShouldBe(DriverState.Unknown, "a superseded attempt must not write Healthy or Faulted"); await Should.ThrowAsync(() => drv.ReadAsync(["W0"], ct)); // Positive control: the driver is not wedged — a fresh initialize publishes normally. await drv.InitializeAsync("{}", ct).WaitAsync(Bound, ct); drv.GetHealth().State.ShouldBe(DriverState.Healthy); (await drv.ReadAsync(["W0"], ct))[0].StatusCode.ShouldBe(0u); } /// /// Review m-4: a retained poll that runs into a re-initialize in progress ("not initialized") /// must not write Degraded over the new attempt's Initializing. Waits on the poll's own log line /// (a counted signal), then asserts; the control is that a successful init then reports Healthy. /// [Fact] public async Task A_retained_poll_does_not_overwrite_a_reinitialize_in_progress() { var ct = TestContext.Current.CancellationToken; var factory = new ScriptedS7PlcFactory(); var secondOpenEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var releaseSecondOpen = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); factory.Open = async (index, token) => { if (index != 1) return; secondOpenEntered.TrySetResult(); await releaseSecondOpen.Task.WaitAsync(token); }; var log = new CapturingS7Logger(); using var drv = new S7Driver(Options(), "s7-retained-poll", factory, log); await drv.InitializeAsync("{}", ct); await drv.SubscribeAsync(["W0"], TimeSpan.FromMilliseconds(100), ct); var reinit = drv.InitializeAsync("{}", ct); await secondOpenEntered.Task.WaitAsync(Bound, ct); using (var cts = CancellationTokenSource.CreateLinkedTokenSource(ct)) { cts.CancelAfter(Bound); while (log.Entries.Count(e => e.Message.Contains("during a lifecycle operation")) < 2) await Task.Delay(20, cts.Token); // condition wait on the poll's counted log line } drv.GetHealth().State.ShouldBe(DriverState.Initializing, "a retained poll must not write over the new attempt"); releaseSecondOpen.SetResult(); await reinit.WaitAsync(Bound, ct); drv.GetHealth().State.ShouldBe(DriverState.Healthy); } /// /// Review m-8: the SYNCHRONOUS Dispose() racing a still-running ShutdownAsync is safe too /// — neither throws, the superseded init publishes nothing, and the driver ends torn down. /// [Fact] public async Task Sync_dispose_while_a_shutdown_is_still_running_is_safe() { var ct = TestContext.Current.CancellationToken; var f = new FirstOpenBlocks(); var drv = new S7Driver(Options(), "s7-sync-dispose-race", f.Factory) { LifecycleGateTimeout = TimeSpan.FromSeconds(1) }; var init = drv.InitializeAsync("{}", ct); await f.FirstOpenEntered.Task.WaitAsync(Bound, ct); var shutdown = drv.ShutdownAsync(ct); // waits on the gate the blocked init holds Should.NotThrow(() => drv.Dispose()); await shutdown.WaitAsync(Bound, ct); f.ReleaseFirstOpen.SetResult(); await Should.ThrowAsync(() => init.WaitAsync(Bound, ct)); f.Factory.Created.ShouldHaveSingleItem().Disposed.ShouldBeTrue(); drv.GetHealth().State.ShouldBe(DriverState.Unknown); } /// /// Dispose racing a shutdown that is still running is safe: neither throws, and the driver ends /// torn down. The semaphores are no longer disposed, so the running shutdown cannot hit an /// ObjectDisposedException. /// [Fact] public async Task Dispose_while_a_shutdown_is_still_running_is_safe() { var ct = TestContext.Current.CancellationToken; var f = new FirstOpenBlocks(); var drv = new S7Driver(Options(), "s7-dispose-race", f.Factory) { LifecycleGateTimeout = TimeSpan.FromSeconds(1) }; var init = drv.InitializeAsync("{}", ct); await f.FirstOpenEntered.Task.WaitAsync(Bound, ct); var shutdown = drv.ShutdownAsync(ct); // waits on the gate the blocked init holds var dispose = drv.DisposeAsync().AsTask(); await shutdown.WaitAsync(Bound, ct); await dispose.WaitAsync(Bound, ct); f.ReleaseFirstOpen.SetResult(); await Should.ThrowAsync(() => init.WaitAsync(Bound, ct)); f.Factory.Created.ShouldHaveSingleItem().Disposed.ShouldBeTrue(); drv.GetHealth().State.ShouldBe(DriverState.Unknown); } }