using Shouldly; using ZB.MOM.WW.OtOpcUa.Tests; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.FOCAS; namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; /// /// #630 — InitializeAsync on a live driver with no teardown first (the IDriver contract /// allows it) overwrote _devices[host] and orphaned the previous state's /// probe loop + client. It now releases them first. /// [Trait("Category", "Unit")] public sealed class FocasReinitializeReleasesStateTests(ITestOutputHelper output) { /// Initialize twice: the first state's probe loop has finished and its client is disposed. [Fact] public async Task SecondInitialize_StopsFirstStatesLoop_AndDisposesItsClient() { const string host = "focas://10.0.0.5:8193"; var factory = new FakeFocasClientFactory(); var options = new FocasDriverOptions { Devices = [new FocasDeviceOptions(host)], RawTags = [FocasRawTags.Entry(new FocasTagDefinition("R", host, "R100", FocasDataType.Byte))], Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(20), Timeout = TimeSpan.FromSeconds(2) }, }; var drv = new FocasDriver(options, "focas-reinit", factory); await FixtureCleanup.RunAsync(async () => { await drv.InitializeAsync("{}", CancellationToken.None); var first = drv.GetDeviceState(host)!; // Positive control: the first state's probe loop is demonstrably running and holds a live client. await WaitUntilAsync(() => factory.Clients.Count > 0 && factory.Clients[0].IsConnected); var firstLoop = first.ProbeTask; _ = firstLoop.ShouldNotBeNull(); firstLoop!.IsCompleted.ShouldBeFalse(); var firstClient = factory.Clients[0]; await drv.InitializeAsync("{}", CancellationToken.None); firstLoop.IsCompleted.ShouldBeTrue(); // joined firstClient.DisposeCount.ShouldBeGreaterThanOrEqualTo(1); firstClient.IsConnected.ShouldBeFalse(); drv.GetDeviceState(host).ShouldNotBeSameAs(first); _ = drv.GetDeviceState(host)!.ProbeTask.ShouldNotBeNull(); // the new state is running }, () => drv.ShutdownAsync(CancellationToken.None), failure => output.WriteLine($"FOCAS SecondInitialize cleanup failed: {failure}")); } /// Failure after a live first or replacement state must release both owned resources. [Theory] [InlineData(false)] [InlineData(true)] public async Task Body_failure_releases_live_probe_resources(bool afterReplacement) { const string host = "focas://10.0.0.5:8193"; var factory = new FakeFocasClientFactory(); var options = new FocasDriverOptions { Devices = [new FocasDeviceOptions(host)], RawTags = [FocasRawTags.Entry(new FocasTagDefinition("R", host, "R100", FocasDataType.Byte))], Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(20), Timeout = TimeSpan.FromSeconds(2) }, }; var drv = new FocasDriver(options, "focas-reinit", factory); Task? firstLoop = null, secondLoop = null; FakeFocasClient? firstClient = null, secondClient = null; var sentinel = new InvalidOperationException("body sentinel"); var actual = await Should.ThrowAsync(() => FixtureCleanup.RunAsync(async () => { await drv.InitializeAsync("{}", CancellationToken.None); await WaitUntilAsync(() => factory.Clients.Count > 0 && factory.Clients[0].IsConnected); firstClient = factory.Clients[0]; firstLoop = drv.GetDeviceState(host)!.ProbeTask; _ = firstLoop.ShouldNotBeNull(); firstLoop!.IsCompleted.ShouldBeFalse(); if (afterReplacement) { await drv.InitializeAsync("{}", CancellationToken.None); await WaitUntilAsync(() => factory.Clients.Count > 1 && factory.Clients[1].IsConnected); secondClient = factory.Clients[1]; secondLoop = drv.GetDeviceState(host)!.ProbeTask; _ = secondLoop.ShouldNotBeNull(); secondLoop!.IsCompleted.ShouldBeFalse(); } throw sentinel; }, () => drv.ShutdownAsync(CancellationToken.None), failure => output.WriteLine($"FOCAS failure fixture cleanup failed: {failure}"))); actual.ShouldBeSameAs(sentinel); firstLoop!.IsCompleted.ShouldBeTrue(); firstClient!.DisposeCount.ShouldBeGreaterThanOrEqualTo(1); firstClient.IsConnected.ShouldBeFalse(); if (afterReplacement) { secondLoop!.IsCompleted.ShouldBeTrue(); secondClient!.DisposeCount.ShouldBeGreaterThanOrEqualTo(1); secondClient.IsConnected.ShouldBeFalse(); } } private static async Task WaitUntilAsync(Func cond) { var deadline = DateTime.UtcNow.AddSeconds(5); while (!cond()) { if (DateTime.UtcNow > deadline) throw new TimeoutException("condition not met"); await Task.Delay(10); } } }