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());