using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime; namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime; /// /// Review 2026-10-21 G-3: allocated a /// new per recovery cycle and only the last one was ever /// disposed. Each cycle now releases its own CTS when its loop exits, and /// awaits the loop instead of blocking on it. /// public sealed class ReconnectSupervisorCycleCtsTests { private const int WaitMs = 10_000; private static ReconnectOptions FastBackoff() => new(InitialBackoffOverride: TimeSpan.FromMilliseconds(5), MaxBackoffOverride: TimeSpan.FromMilliseconds(20)); /// Three completed cycles ⇒ three released CTSs; the in-flight one is not released early (positive control). [Fact] public async Task Every_completed_recovery_cycle_releases_its_own_cts() { var holdReopen = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var reopenEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var hold = new[] { false }; // read on the recovery loop's thread, written here — Volatile both ways using var sup = new ReconnectSupervisor( reopen: async _ => { if (Volatile.Read(ref hold[0])) { reopenEntered.TrySetResult(); await holdReopen.Task; } }, replay: _ => Task.CompletedTask, options: FastBackoff()); for (var i = 0; i < 3; i++) { sup.ReportTransportFailure(new IOException($"drop {i}")); await sup.WaitForHealthyAsync(new CancellationTokenSource(WaitMs).Token); } await WaitUntilAsync(() => sup.CyclesReleased == 3, "each completed cycle must release its CTS"); sup.CyclesStarted.ShouldBe(3); // Positive control: a cycle parked inside reopen still holds its CTS. Volatile.Write(ref hold[0], true); sup.ReportTransportFailure(new IOException("drop 3")); (await Task.WhenAny(reopenEntered.Task, Task.Delay(WaitMs))).ShouldBe(reopenEntered.Task); sup.CyclesStarted.ShouldBe(4); sup.CyclesReleased.ShouldBe(3, "an in-flight cycle's CTS is live until its loop exits"); holdReopen.TrySetResult(); await sup.WaitForHealthyAsync(new CancellationTokenSource(WaitMs).Token); await WaitUntilAsync(() => sup.CyclesReleased == 4, "the fourth cycle must release its CTS on exit"); } /// StopAsync cancels a parked cycle, awaits its exit and releases its CTS without blocking. [Fact] public async Task StopAsync_cancels_the_in_flight_cycle_and_releases_its_cts() { var reopenEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var sup = new ReconnectSupervisor( reopen: async ct => { reopenEntered.TrySetResult(); await Task.Delay(Timeout.Infinite, ct); }, replay: _ => Task.CompletedTask, options: FastBackoff()); sup.ReportTransportFailure(new IOException("drop")); (await Task.WhenAny(reopenEntered.Task, Task.Delay(WaitMs))).ShouldBe(reopenEntered.Task); var stop = sup.StopAsync(); (await Task.WhenAny(stop, Task.Delay(WaitMs))).ShouldBe(stop, "StopAsync must cancel the parked reopen and return"); sup.CyclesReleased.ShouldBe(1); await Should.NotThrowAsync(sup.StopAsync); // idempotent Should.NotThrow(sup.Dispose); } private static async Task WaitUntilAsync(Func condition, string because) { var deadline = DateTime.UtcNow.AddMilliseconds(WaitMs); while (!condition()) { if (DateTime.UtcNow > deadline) throw new ShouldAssertException(because); await Task.Delay(10); } } }