Firewall.LoadFrom and ToSettings run on the game loop (Configure sweep, the
maintenance timer, Save on shutdown) but read DateTime.UtcNow. ToSettings was
the one that mattered: it derives each persisted expiry as
now + (expiresAtTick - nowTicks) while nowTicks came from Core.TickCount, so
pairing a fresh wall clock with the loop's tick baked the loop's lag into every
saved TTL. Core.Now and Core.TickCount are refreshed together at the top of each
iteration, so taking both keeps the operands on one instant.
Left DateTime.UtcNow in CrowdSecAlertClient and the reporter's flush/drain
paths, which run on the pool and have no loop clock to read.
Neither test fixture seeded Core._now, so Core.Now was DateTime.MinValue for the
whole test host -- MinValue.AddHours(-1) throws, and any code correctly reading
the game-thread clock computed nonsense. Seed it as Main.cs does.
Also fixes a dangling collection reference: the firewall tests moved into
UOContent.Tests still declared [Collection("Sequential Server Tests")], which is
only defined in Server.Tests. xUnit matched no fixture and silently skipped the
bootstrap for those tests.
Comment pass over the branch: drop development narration and tighten what stays.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Applies the house conventions consistently across this PR's own code rather
than leaving them half-applied: BanChannel had Register/Stop converted while
Start kept a foreach, and ConnectionFilters was foreach throughout.
Converted every foreach over an array or List, and while doing so hoisted the
_reporters/_filters static field reads into a local so the loops match the
snapshot pattern Report/Retract/ShouldDeny already use.
Left as foreach where there is no indexer: the Dictionary walks in
Firewall.ExpireEntries, PromotedGuard.Sweep and BuildAlerts, and BuildAlerts'
IEnumerable parameter.
Dictionary field initializers become [] (it compiles, same lowering). The three
remaining new List<T>(capacity) calls keep their form -- a collection
expression cannot carry the capacity hint, and all three size the list exactly.
Scoped to files this PR authored or moved; pre-existing loops in AdminGump,
Main and Utility are left alone.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every other reporter test reaches Stop() without a Start(), so the drain task
was never exercised and the Task<ValueTask> defect passed the whole suite while
doing nothing. Race the drain against a delay: 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. Then assert Stop() does not
return until the loop has actually exited, which is the precondition the flush
relies on before reading the SingleReader channel.
Verified by reverting DrainLoop to ValueTask: the test fails. A slow pool can
only make it under-detect, never fail spuriously.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>