Two defects in CrowdSecReporter shutdown, both reachable only on a shard that
actually has CrowdSec configured with items still queued.
1. FlushRemainingOnStop blocked with GetAwaiter().GetResult() on a ValueTask.
Beyond being unsupported in general -- an IValueTaskSource-backed ValueTask
does not wait there, it throws -- it hung. Stop() runs on the main thread,
where Main installs EventLoopContext as SynchronizationContext.Current, and
nothing in CrowdSecAlertClient used ConfigureAwait(false), so the send's
continuation was posted to a queue only LoopContext.ExecuteTasks() drains --
and by HandleClosed() the loop has stopped ticking. The thread waited on a
continuation only that thread could run. The 3s budget did not help: it
completes the HTTP call, not the resumption.
Run the flush through Task.Run so the whole chain lives on the pool with no
context to capture, block once on a real Task, and bound it with Wait so a
wedged send costs a few seconds of shutdown rather than the process. Add
ConfigureAwait(false) across the client and drain loop per rule #10 so the
pool hop is defense in depth rather than the only thing holding it up.
2. Start() did Task.Run(() => DrainLoop(...)) where DrainLoop returned
ValueTask. There is no Task.Run(Func<ValueTask>) overload, so it bound to
Task.Run<TResult>(Func<TResult>) and produced a Task<ValueTask> that
completes at the first suspending await, not when the loop exits -- silently
upcast by the Task _drainTask field. Stop()'s drain-exited handshake was
therefore a no-op (Wait returned true in ~0ms), letting the flush read a
SingleReader channel concurrently with a live drain loop, which is precisely
the hazard that handshake documents itself as preventing. It also made the
loop fire-and-forget and swallowed any fault escaping it.
Return Task so it binds to Task.Run(Func<Task>) and unwraps. A loop awaited
once has nothing to gain from ValueTask.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Add a pluggable ban seam: BanChannel fans locally-decided bans out to
IBanReporter sinks (Report/Retract) and the accept path enforces locally.
CrowdSec is a write-only reporter living in UOContent (registered into the
Core seam via BanChannel.Register) — it batches decisions onto a bounded,
coalescing, drop-on-overflow queue and POSTs /v1/alerts with watcher creds,
retry/backoff, and a bounded flush-on-stop. CrowdSec never enforces in-app.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>