diff --git a/src/Controls/src/Core/AnimationExtensions.cs b/src/Controls/src/Core/AnimationExtensions.cs index be24d3dc042b..bdef89f81ff3 100644 --- a/src/Controls/src/Core/AnimationExtensions.cs +++ b/src/Controls/src/Core/AnimationExtensions.cs @@ -28,6 +28,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Threading; using Microsoft.Maui.Animations; using Microsoft.Maui.Controls.Internals; using Microsoft.Maui.Dispatching; @@ -64,7 +65,7 @@ static AnimationExtensions() public static int Add(this IAnimationManager animationManager, Action step) { - var id = s_currentTweener++; + var id = Interlocked.Increment(ref s_currentTweener); var animation = new Animation { Name = $"{id}", @@ -84,7 +85,7 @@ public static int Add(this IAnimationManager animationManager, Action st public static int Insert(this IAnimationManager animationManager, Func step) { - var id = s_currentTweener++; + var id = Interlocked.Increment(ref s_currentTweener); Animation animation = null; animation = new TweenerAnimation(step) { diff --git a/src/Controls/tests/Core.UnitTests/AnimationExtensionsThreadSafetyTests.cs b/src/Controls/tests/Core.UnitTests/AnimationExtensionsThreadSafetyTests.cs new file mode 100644 index 000000000000..2e2f37e05148 --- /dev/null +++ b/src/Controls/tests/Core.UnitTests/AnimationExtensionsThreadSafetyTests.cs @@ -0,0 +1,88 @@ +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Maui.Animations; +using Xunit; + +namespace Microsoft.Maui.Controls.Core.UnitTests +{ + public class AnimationExtensionsThreadSafetyTests + { + [Fact] + public void Add_ConcurrentCalls_ProducesUniqueIds() + { + // Exercises the REAL AnimationExtensions.Add() from multiple threads. + // If the fix is reverted (s_currentTweener++ instead of Interlocked.Increment), + // this test will fail with duplicate IDs under contention. + var manager = new NoOpAnimationManager(); + const int threadCount = 50; + const int callsPerThread = 200; + var ids = new ConcurrentBag(); + + var tasks = new Task[threadCount]; + for (int i = 0; i < threadCount; i++) + { + tasks[i] = Task.Run(() => + { + for (int j = 0; j < callsPerThread; j++) + { + int id = manager.Add(_ => { }); + ids.Add(id); + } + }); + } + + Task.WaitAll(tasks); + + int expectedCount = threadCount * callsPerThread; + var uniqueIds = new HashSet(ids); + + Assert.Equal(expectedCount, ids.Count); + Assert.Equal(expectedCount, uniqueIds.Count); + } + + [Fact] + public void Insert_ConcurrentCalls_ProducesUniqueIds() + { + // Same as above but exercises AnimationExtensions.Insert(). + var manager = new NoOpAnimationManager(); + const int threadCount = 50; + const int callsPerThread = 200; + var ids = new ConcurrentBag(); + + var tasks = new Task[threadCount]; + for (int i = 0; i < threadCount; i++) + { + tasks[i] = Task.Run(() => + { + for (int j = 0; j < callsPerThread; j++) + { + int id = manager.Insert(_ => true); + ids.Add(id); + } + }); + } + + Task.WaitAll(tasks); + + int expectedCount = threadCount * callsPerThread; + var uniqueIds = new HashSet(ids); + + Assert.Equal(expectedCount, ids.Count); + Assert.Equal(expectedCount, uniqueIds.Count); + } + + /// + /// Minimal no-op IAnimationManager so AnimationExtensions.Add/Insert can run + /// without requiring a platform ticker (which would block or crash off-thread). + /// + sealed class NoOpAnimationManager : IAnimationManager + { + public ITicker Ticker => null!; + public double SpeedModifier { get; set; } = 1; + public bool AutoStartTicker { get; set; } + public void Add(Microsoft.Maui.Animations.Animation animation) { } + public void Remove(Microsoft.Maui.Animations.Animation animation) { } + } + } +}