Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/ReconnectSupervisorCycleCtsTests.cs
T
Joseph Doherty b8202e1147
v2-ci / unit-tests (push) Has been cancelled
test: remaining xUnit1051/1031/1030, CS8602/8604, CS0618, CS0219, CS0649, NU1510 fixes (lmxopcua#725)
Awaited deadline oracles get the runner token; sync-over-async tests made async;
nullable child models asserted via ShouldNotBeNull() return values; obsolete SDK
ctors replaced with the telemetry overloads; dead locals/fixture and the redundant
DI PackageReference removed. No suppressions.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 13:22:34 -05:00

93 lines
4.2 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
/// <summary>
/// Review 2026-10-21 G-3: <see cref="ReconnectSupervisor.ReportTransportFailure"/> allocated a
/// new <see cref="CancellationTokenSource"/> per recovery cycle and only the last one was ever
/// disposed. Each cycle now releases its own CTS when its loop exits, and
/// <see cref="ReconnectSupervisor.StopAsync"/> awaits the loop instead of blocking on it.
/// </summary>
public sealed class ReconnectSupervisorCycleCtsTests
{
private const int WaitMs = 10_000;
private static ReconnectOptions FastBackoff() =>
new(InitialBackoffOverride: TimeSpan.FromMilliseconds(5), MaxBackoffOverride: TimeSpan.FromMilliseconds(20));
/// <summary>Three completed cycles ⇒ three released CTSs; the in-flight one is not released early (positive control).</summary>
[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, TestContext.Current.CancellationToken))).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");
}
/// <summary>StopAsync cancels a parked cycle, awaits its exit and releases its CTS without blocking.</summary>
[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, TestContext.Current.CancellationToken))).ShouldBe(reopenEntered.Task);
var stop = sup.StopAsync();
(await Task.WhenAny(stop, Task.Delay(WaitMs, TestContext.Current.CancellationToken))).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<bool> condition, string because)
{
var deadline = DateTime.UtcNow.AddMilliseconds(WaitMs);
while (!condition())
{
if (DateTime.UtcNow > deadline) throw new ShouldAssertException(because);
await Task.Delay(10);
}
}
}