Commit graph

7 commits

Author SHA1 Message Date
Kamron Batman
99fc33ad47
fix(firewall): use Core.Now on the game thread; seed the test clock
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>
2026-07-25 11:30:52 -07:00
Kamron Batman
9df169946c
style: use indexed for over arrays/lists and [] initializers
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>
2026-07-25 11:17:33 -07:00
Kamron Batman
0f64d834b4
test(crowdsec): pin the drain task to the loop's lifetime
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>
2026-07-25 11:11:48 -07:00
Kamron Batman
6f74532280
fix(crowdsec): stop deadlocking and racing shutdown on the flush path
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>
2026-07-25 11:07:30 -07:00
Kamron Batman
357a05f8dc
Minor cleanup 2026-07-25 10:55:17 -07:00
Kamron Batman
4d64a854c2
Simplifies Configure() 2026-07-25 10:52:06 -07:00
Kamron Batman
6249a20f15
feat(bans): contribute-first ban channel with CrowdSec reporter
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>
2026-07-23 20:37:42 -07:00