Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/ReconnectSupervisorCycleCtsTests.cs
T
Joseph Doherty 77ca10aa37 test(galaxy): de-flake writer-lane and supervisor tests; align EffectiveTimeout doc
The writer concurrency fake parks on its release signal (the 300 ms delay could release the
gate early on a starved pool), the supervisor test's cross-thread hold flag is Volatile, and
the EffectiveTimeout doc now states the 1 s floor the code and tests implement.

Refs #735

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 13:29:11 -04:00

93 lines
4.1 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))).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))).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<bool> condition, string because)
{
var deadline = DateTime.UtcNow.AddMilliseconds(WaitMs);
while (!condition())
{
if (DateTime.UtcNow > deadline) throw new ShouldAssertException(because);
await Task.Delay(10);
}
}
}