77ca10aa37
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
93 lines
4.1 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|