SetPassword derives a full Argon2 hash, so the [password command, the admin
gump, account creation and the XML import each cost ~8.9 ms of frozen world.
Only the login verify had been moved.
DRY-ing the two paths surfaced a correctness trap rather than just shared
code. ApplyPasswordUpgrade guarded by comparing the stored hash, which is
right for a login rehash -- do not clobber a newer password with a rehash of
the one it superseded -- but wrong for an explicit change: two changes
dispatched before either landed would drop the second and silently keep the
older password. Ordering is now a per-account dispatch sequence claimed on
the loop, which gives "newest wins" for both callers through one mechanism.
SetPassword bumps it as well, so an inline write also supersedes an in-flight
one.
One job type serves both: PasswordJob carries an optional verify phrase and
an optional hash phrase plus an OnComplete that runs on the loop, so a login
verifies and may rehash while a password change only hashes. The class is
PasswordWorker now, since verification no longer describes what it does.
PasswordWorker.SetPassword is the single entry point and falls back to
hashing inline when the gate is off or the queue is saturated -- unlike a
login, a password change must never be silently dropped, and it is rare
enough that the loop can absorb one.
The [password confirmation moves into the callback, because off the loop it
has not happened when the call returns. Account creation stays inline: it
gates the login flow, so deferring it restructures the accept path.
The threading model's forbidden-patterns table bans new Thread,
ConcurrentQueue, Interlocked, Semaphore and volatile in UOContent, and its
exceptions list covered only Projects/Server. Both the new password
verification worker and the existing Advanced Search fan-out already sat
outside it, so the rule as written flagged working code.
Adds a vetted-workers section that leads with proving the need rather than
listing primitives: measure on-loop time rather than wall-clock, gate on core
count because off-loading creates no CPU, and account for what stays behind.
Game logic is still never threaded -- if it is too slow for one tick it gets
chunked across ticks, and that is spelled out with an example so the
distinction is not left implicit. Then the five rules a worker must satisfy,
and the hand-off protocol in both directions, including that the continuation
must re-validate anything that could have moved and that a failure must still
post a verdict.
Both vetted workers are listed with their justification, so a future reader
can tell whether either still earns its complexity.
CLAUDE.md rule #10 gains the same "prove it first" clause. Rules #3 and #10
already covered chunking, yielding to saves, and both hand-back routes.
Also trims the implementation comments: the measurement narrative belongs in
the handoff document, not in a class header.
Two fixes to the pending queue.
A queued job whose client has gone was still costing a full ~9 ms verify for
a verdict nobody would receive. It is now dropped at dequeue, which reclaims
the slot immediately -- and that matters most during the reconnect rush that
builds a queue in the first place. Running only ever transitions true to
false, so reading it off-thread can waste a hash but can never skip a live
connection.
The 128 cap was a second, far tighter bound on something the engine already
bounds. AccountLogin rejects a duplicate 0x80 via SentFirstPacket, so a
connection holds at most one pending verify, and concurrent connections are
capped at 4096. The cap now matches that, making it a backstop against the
one-per-connection invariant breaking rather than a policy that fires first
on real players -- an 800-client boot rush was eight times over the old
limit.
It was never a flood defence and no longer claims to be. A cap low enough to
blunt an attack rejects legitimate logins for its duration, which is the
denial of service arriving by another route; a cap high enough to avoid that
blunts nothing. That belongs at the connection layer, where IP bans and the
accept gate stop an attacker before he costs a hash.
An Argon2 verify is ~8.9 ms of frozen world per login attempt, successful or
not, so a credential flood is a full-cost stall per packet without needing
valid credentials. Measurement puts the on-loop saving at 3.5-8.9 ms: the
hand-off costs ~220 ns, and the only real residue is the loop's own work
slowing while a memory-hard KDF evicts shared L3.
One worker, not a pool. The per-login contention tax falls with concurrency
while total loop damage rises, so one hasher harms the loop least; and a
single hasher cannot cost the loop more than the inline verify under any
scheduling regime, because at worst it takes an equal share of one core.
That bound is what lets the measurement extrapolate to hardware we cannot
inspect, and a pool breaks it. It also caps live Argon2 arenas at one,
which answers memory exhaustion without a separate mechanism.
Scoped to Argon2-stored accounts. SHA and MD5 protections share a
HashAlgorithm instance whose ComputeHash is not thread safe, and they cost
microseconds anyway. Their one-time rehash into Argon2 stays on the loop
too: it costs a migrating account a single 8.9 ms login exactly as today.
The worker parks on an AutoResetEvent and never spins. The spin in
SerializationThreadWorker exists to wait on a producer mid-drain; there is
no such race here, so this is strictly cheaper at idle. It yields while the
world is in PendingSave or Saving -- PendingSave included, because the
serialization threads are already awake and spinning on an empty queue by
then.
Phrase derivation moves to AccountSecurity.DerivePhrase so verification and
rehash cannot disagree about the rule, which is the shape of the lockout
fixed in #2562. ApplyPasswordUpgrade refuses to write when the stored hash
changed while the verify ran, so a password set mid-flight is not replaced
by a rehash of the one it superseded.