diff --git a/Projects/UOContent.Tests/Tests/Network/Bans/CrowdSecReporterTests.cs b/Projects/UOContent.Tests/Tests/Network/Bans/CrowdSecReporterTests.cs index 937be3fb5..c6b83706f 100644 --- a/Projects/UOContent.Tests/Tests/Network/Bans/CrowdSecReporterTests.cs +++ b/Projects/UOContent.Tests/Tests/Network/Bans/CrowdSecReporterTests.cs @@ -160,6 +160,44 @@ public class CrowdSecReporterTests Assert.Equal(0, reporter.SendFailureCount); } + /// + /// Regression for the two shapes that made the drain task lie about the loop's lifetime. + /// + /// + /// hands the drain loop to , + /// and there is no Task.Run(Func<ValueTask>) overload — a ValueTask-returning drain loop + /// binds to Task.Run<TResult>(Func<TResult>) and yields a Task<ValueTask> + /// that completes at the first suspending await rather than when the loop exits, silently upcast by + /// the field. That turned 's drain-exited + /// handshake into a no-op and let the shutdown flush read the SingleReader channel while the + /// drain loop was still reading it. Every other test here reaches Stop() without a Start(), so + /// nothing covered it. + /// + /// Racing the drain against a delay is what separates the two shapes: with an empty queue the loop + /// parks on WaitToReadAsync forever, so a correctly unwrapped task cannot win that race, while + /// the Task<ValueTask> completed in ~0ms. A slow pool can only make this under-detect, + /// never fail spuriously. + /// + /// + [Fact] + public async Task Start_DrainTaskSpansLoopLifetime_NotJustTheFirstAwait() + { + var reporter = new CrowdSecReporter(new NullAlertClient(), Settings()); + using var cts = new CancellationTokenSource(); + + reporter.Start(cts.Token); + + var drain = reporter.DrainTaskForTesting; + Assert.NotNull(drain); + + var first = await Task.WhenAny(drain, Task.Delay(TimeSpan.FromMilliseconds(500))); + Assert.False(ReferenceEquals(first, drain), "drain task completed while the loop was still running"); + + reporter.Stop(); + + Assert.True(drain.IsCompleted, "Stop() returned before the drain loop exited"); + } + private sealed class NullAlertClient : ICrowdSecAlertClient { public ValueTask PostAlertsAsync(IReadOnlyList alerts, CancellationToken token) => diff --git a/Projects/UOContent/Misc/CrowdSec/CrowdSecReporter.cs b/Projects/UOContent/Misc/CrowdSec/CrowdSecReporter.cs index 84cfbdf31..77334f02f 100644 --- a/Projects/UOContent/Misc/CrowdSec/CrowdSecReporter.cs +++ b/Projects/UOContent/Misc/CrowdSec/CrowdSecReporter.cs @@ -69,6 +69,12 @@ public sealed class CrowdSecReporter : IBanReporter /// public int SendFailureCount => _sendFailures; + /// + /// The drain loop's task, so tests can observe its lifetime. It must stay incomplete until the loop + /// actually exits — see the note on for the shape that silently broke that. + /// + internal Task DrainTaskForTesting => _drainTask; + public static void Configure() { BanChannel.Register(new CrowdSecReporter());