Absorb the remaining off-funnel mutation races against the periodic save

The per-player persistence lock serializes the packet handler funnel and the
save, but a few structural mutations happen off that funnel: the offline/bot
MuHelper loots and maintains its inventory on a 500ms timer, and combat
destroys depleted ammunition or a dead pet on the attacker's or a monster's
thread. Those can still run while the periodic save enumerates the change
tracker and corrupt it.

Two additions:

- Run the whole offline MuHelper tick under the player's persistence lock.
  Bots are in the saved player list and loot continuously, so this was the
  most likely remaining reproducer. The tick has no internal delays, so the
  lock is held only briefly, and it is the bot's own lock (no cross-player
  deadlock).

- Retry the save a bounded number of times on the transient exceptions a
  concurrent change-tracker mutation produces. The corruption surfaces as
  several types depending on where change detection was (a modified
  collection, a transiently-null key, an out-of-range index), so the retry
  covers that family rather than a single type. A genuinely persistent error
  rethrows once the attempts are exhausted. This absorbs the rare, bursty
  combat sources that no lock is held for.
This commit is contained in:
nolt
2026-07-27 00:39:43 +02:00
committed by Acentech Dev
parent 7671fe2d94
commit d3dd57f620

View File

@@ -141,7 +141,11 @@ public sealed class OfflinePlayerMuHelper : AsyncDisposable
{
try
{
await this.TickAsync(cancellationToken).ConfigureAwait(false);
// Run the whole tick under the player's persistence lock so its structural mutations
// (loot pickup, combat ammo/pet destruction, queued equip/jewel actions) never overlap
// this bot's periodic progress save, which runs on a separate timer. The tick has no
// internal delays, so the lock is held only for its brief duration.
await this._player.RunPersistenceExclusiveAsync(() => this.TickAsync(cancellationToken)).ConfigureAwait(false);
this._player.OnAiTickSucceeded();
}
catch (OperationCanceledException)