Flush() allocated two fresh arrays sized to the live entry count and copied
the whole dictionary into them, on the game loop, every 60s. UInt128 is 16
bytes, so past ~5,300 entries that first array is an LOH allocation once a
minute, forever -- and a large shard accumulates thousands of addresses
easily. The file write was already off-loop; the walk was not.
Buffers are now static, grown geometrically and never shrunk, so a steady
shard stops allocating here entirely. The writer owns them until it posts
completion back through Core.LoopContext, so a flush landing mid-write
waits rather than overwriting them (CLAUDE.md rule #10 -- _writing and
_dirty stay loop state and the writer never touches them itself).
Default flush interval goes to an hour. The data has a 90-day TTL, so a
minute was never buying anything, and the interval now only bounds what a
crash loses:
- Clean shutdown writes synchronously via EventSink.Shutdown. Handing off
would reach no disk; nothing schedules after that point.
- HandleClosed skips InvokeShutdown when the server crashed, so the crash
path subscribes separately, following PasswordWorker's precedent. The
handler runs on whichever thread faulted -- the loop thread for anything
out of RunEventLoop, but not necessarily -- and the dictionaries are loop
state, so it only writes when it is actually on the loop.
Also fixes a pre-existing hole: _dirty was cleared before the write, so a
failed write dropped the entries until the next RecordLogin happened to
set it again, despite the comment promising a retry. Write now reports
success and _dirty is restored when nothing reached disk.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>