// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // namespace MUnique.OpenMU.Tests; using System.Threading; /// /// Tests for the per-player persistence lock (), /// which serializes a player's context mutations against its periodic/disconnect progress saves so they /// can never run concurrently. Without it, a mutation running during SaveChangesAsync corrupts the /// change tracker and rolls the whole session back. /// [TestFixture] public class PersistenceLockTest { /// /// Verifies that concurrent exclusive operations for the same player never overlap. /// [Test] public async Task ConcurrentAccessIsSerializedAsync() { var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false); var concurrent = 0; var overlapDetected = false; async ValueTask BodyAsync() { if (Interlocked.Increment(ref concurrent) > 1) { overlapDetected = true; } await Task.Delay(1).ConfigureAwait(false); Interlocked.Decrement(ref concurrent); } var tasks = Enumerable.Range(0, 50) .Select(_ => player.RunPersistenceExclusiveAsync(BodyAsync).AsTask()) .ToArray(); await Task.WhenAll(tasks).ConfigureAwait(false); Assert.That(overlapDetected, Is.False, "Two exclusive operations for the same player ran at the same time."); } /// /// Verifies that re-entering the lock from within an already-held exclusive scope does not deadlock /// (an inline save inside a packet handler is exactly this case). /// [Test] public async Task ReentrantAccessDoesNotDeadlockAsync() { var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false); var executed = 0; var run = player.RunPersistenceExclusiveAsync(async () => { Interlocked.Increment(ref executed); await player.RunPersistenceExclusiveAsync(async () => { Interlocked.Increment(ref executed); await Task.Yield(); }).ConfigureAwait(false); }).AsTask(); // If reentrancy deadlocked, this would hang; fail fast instead of blocking the suite. await run.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); Assert.That(executed, Is.EqualTo(2)); } /// /// Verifies that a re-entrant exclusive operation still runs while another flow holds the lock: /// the outer flow keeps the lock, an independent flow must wait, and the re-entrant call inside the /// outer flow proceeds without waiting for itself. /// [Test] public async Task IndependentFlowWaitsWhileLockIsHeldAsync() { var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false); var otherEntered = false; var holderHasLock = new TaskCompletionSource(); var mayRelease = new TaskCompletionSource(); // Holder runs on its own flow and keeps the lock until signalled. var holder = Task.Run(() => player.RunPersistenceExclusiveAsync(async () => { holderHasLock.SetResult(); // A re-entrant call from the holding flow must NOT block on the lock we already hold. await player.RunPersistenceExclusiveAsync(() => ValueTask.CompletedTask).ConfigureAwait(false); await mayRelease.Task.ConfigureAwait(false); }).AsTask()); await holderHasLock.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); // Competing flow started from an INDEPENDENT context (does not inherit the reentrancy flag). var other = Task.Run(() => player.RunPersistenceExclusiveAsync(() => { otherEntered = true; return ValueTask.CompletedTask; }).AsTask()); await Task.Delay(50).ConfigureAwait(false); Assert.That(otherEntered, Is.False, "An independent flow entered while the lock was held."); mayRelease.SetResult(); await holder.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); await other.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); Assert.That(otherEntered, Is.True, "The competing flow never ran after the lock was released."); } }