A player's progress is persisted from two unrelated flows: action handlers triggered by incoming packets (sequential per connection) and the periodic save, which runs on an independent timer. Action handlers mutate tracked entities with plain field and collection writes - for example crafting toggles item.ItemOptions, and item stacking and NPC selling delete item rows - which bypass the persistence context's own lock. When such a mutation runs while SaveChangesAsync enumerates the change tracker, the save throws (collection-modified, or a DbUpdateConcurrency "affected 0 rows" since the context has no concurrency tokens). SaveChanges is atomic, so every following save fails too and the whole session never persists: on relog the player rolls back, losing progress and items. Add a per-player re-entrant persistence lock and acquire it around both the packet-handling funnel and SaveProgressAsync, so a player's mutations and saves can never overlap. The lock is re-entrant per asynchronous flow (an instance AsyncLocal), so an inline save inside an already-serialized handler does not deadlock; and it is per player, so a trade still acquires the trading partner's lock separately. Add regression tests covering mutual exclusion and re-entrancy of the lock.
4.6 KiB
4.6 KiB