using System; using System.Collections.Generic; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Linq; using System.Threading; using System.Threading.Tasks; using Avalonia.Controls.Platform; using Avalonia.Threading; using Avalonia.Utilities; using Xunit; namespace Avalonia.Base.UnitTests; public partial class DispatcherTests { class SimpleDispatcherImpl : IDispatcherImpl, IDispatcherImplWithPendingInput { private readonly Thread _loopThread = Thread.CurrentThread; private readonly object _lock = new(); public bool CurrentThreadIsLoopThread => Thread.CurrentThread == _loopThread; public void Signal() { lock (_lock) AskedForSignal = true; } public event Action Signaled; public event Action Timer; public long? NextTimer { get; private set; } public bool AskedForSignal { get; private set; } public void UpdateTimer(long? dueTimeInTicks) { NextTimer = dueTimeInTicks; } public long Now { get; set; } public void ExecuteSignal() { lock (_lock) { if (!AskedForSignal) return; AskedForSignal = false; } Signaled?.Invoke(); } public void ExecuteTimer() { if (NextTimer == null) return; Now = NextTimer.Value; Timer?.Invoke(); } public bool CanQueryPendingInput => TestInputPending != null; public bool HasPendingInput => TestInputPending == true; public bool? TestInputPending { get; set; } } class SimpleDispatcherWithBackgroundProcessingImpl : SimpleDispatcherImpl, IDispatcherImplWithExplicitBackgroundProcessing { public bool AskedForBackgroundProcessing { get; private set; } public event Action ReadyForBackgroundProcessing; public void RequestBackgroundProcessing() { if (!CurrentThreadIsLoopThread) throw new InvalidOperationException(); AskedForBackgroundProcessing = true; } public void FireBackgroundProcessing() { if(!AskedForBackgroundProcessing) return; AskedForBackgroundProcessing = false; ReadyForBackgroundProcessing?.Invoke(); } } class SimpleControlledDispatcherImpl : SimpleDispatcherWithBackgroundProcessingImpl, IControlledDispatcherImpl { private readonly bool _useTestTimeout = true; private readonly CancellationToken? _cancel; public int RunLoopCount { get; private set; } public SimpleControlledDispatcherImpl() { } public SimpleControlledDispatcherImpl(CancellationToken cancel, bool useTestTimeout = false) { _useTestTimeout = useTestTimeout; _cancel = cancel; } public void RunLoop(CancellationToken token) { RunLoopCount++; var st = Stopwatch.StartNew(); while (!token.IsCancellationRequested || _cancel?.IsCancellationRequested == true) { FireBackgroundProcessing(); ExecuteSignal(); if (_useTestTimeout) Assert.True(st.ElapsedMilliseconds < 4000, "RunLoop exceeded test time quota"); else Thread.Sleep(10); } } } [Fact] public void DispatcherExecutesJobsAccordingToPriority() { var impl = new SimpleDispatcherImpl(); var disp = new Dispatcher(impl); var actions = new List(); disp.Post(()=>actions.Add("Background"), DispatcherPriority.Background); disp.Post(()=>actions.Add("Render"), DispatcherPriority.Render); disp.Post(()=>actions.Add("Input"), DispatcherPriority.Input); Assert.True(impl.AskedForSignal); impl.ExecuteSignal(); Assert.Equal(new[] { "Render", "Input", "Background" }, actions); } [Fact] public void DispatcherPreservesOrderWhenChangingPriority() { var impl = new SimpleDispatcherImpl(); var disp = new Dispatcher(impl); var actions = new List(); var toPromote = disp.InvokeAsync(()=>actions.Add("PromotedRender"), DispatcherPriority.Background); var toPromote2 = disp.InvokeAsync(()=>actions.Add("PromotedRender2"), DispatcherPriority.Input); disp.Post(() => actions.Add("Render"), DispatcherPriority.Render); toPromote.Priority = DispatcherPriority.Render; toPromote2.Priority = DispatcherPriority.Render; Assert.True(impl.AskedForSignal); impl.ExecuteSignal(); Assert.Equal(new[] { "PromotedRender", "PromotedRender2", "Render" }, actions); } [Fact] public void DispatcherStopsItemProcessingWhenInteractivityDeadlineIsReached() { var impl = new SimpleDispatcherImpl(); var disp = new Dispatcher(impl); var actions = new List(); for (var c = 0; c < 10; c++) { var itemId = c; disp.Post(() => { actions.Add(itemId); impl.Now += 20; }, DispatcherPriority.Background); } Assert.False(impl.AskedForSignal); Assert.NotNull(impl.NextTimer); for (var c = 0; c < 4; c++) { Assert.NotNull(impl.NextTimer); Assert.False(impl.AskedForSignal); impl.ExecuteTimer(); Assert.False(impl.AskedForSignal); impl.ExecuteSignal(); var expectedCount = (c + 1) * 3; if (c == 3) expectedCount = 10; Assert.Equal(Enumerable.Range(0, expectedCount), actions); Assert.False(impl.AskedForSignal); if (c < 3) { Assert.True(impl.NextTimer > impl.Now); } else Assert.Null(impl.NextTimer); } } [Fact] public void DispatcherStopsItemProcessingWhenInputIsPending() { var impl = new SimpleDispatcherImpl(); impl.TestInputPending = true; var disp = new Dispatcher(impl); var actions = new List(); for (var c = 0; c < 10; c++) { var itemId = c; disp.Post(() => { actions.Add(itemId); if (itemId == 0 || itemId == 3 || itemId == 7) impl.TestInputPending = true; }, DispatcherPriority.Background); } Assert.False(impl.AskedForSignal); Assert.NotNull(impl.NextTimer); impl.TestInputPending = false; for (var c = 0; c < 4; c++) { Assert.NotNull(impl.NextTimer); impl.ExecuteTimer(); Assert.False(impl.AskedForSignal); var expectedCount = c switch { 0 => 1, 1 => 4, 2 => 8, 3 => 10, _ => throw new InvalidOperationException($"Unexpected value {c}") }; Assert.Equal(Enumerable.Range(0, expectedCount), actions); Assert.False(impl.AskedForSignal); if (c < 3) { Assert.True(impl.NextTimer > impl.Now); impl.Now = impl.NextTimer.Value + 1; } else Assert.Null(impl.NextTimer); impl.TestInputPending = false; } } [Theory, InlineData(false, false), InlineData(false, true), InlineData(true, false), InlineData(true, true)] public void CanWaitForDispatcherOperationFromTheSameThread(bool controlled, bool foreground) { var impl = controlled ? new SimpleControlledDispatcherImpl() : new SimpleDispatcherImpl(); var disp = new Dispatcher(impl); bool finished = false; disp.InvokeAsync(() => finished = true, foreground ? DispatcherPriority.Default : DispatcherPriority.Background).Wait(); Assert.True(finished); if (controlled) Assert.Equal(foreground ? 0 : 1, ((SimpleControlledDispatcherImpl)impl).RunLoopCount); } class DispatcherServices : IDisposable { private readonly IDisposable _scope; public DispatcherServices(IDispatcherImpl impl) { _scope = AvaloniaLocator.EnterScope(); AvaloniaLocator.CurrentMutable.Bind().ToConstant(impl); Dispatcher.ResetForUnitTests(); SynchronizationContext.SetSynchronizationContext(null); } public void Dispose() { Dispatcher.ResetForUnitTests(); _scope.Dispose(); SynchronizationContext.SetSynchronizationContext(null); } } [Fact] public void ExitAllFramesShouldExitAllFramesAndBeAbleToContinue() { using (new DispatcherServices(new SimpleControlledDispatcherImpl())) { var actions = new List(); var disp = Dispatcher.UIThread; disp.Post(() => { actions.Add("Nested frame"); Dispatcher.UIThread.MainLoop(CancellationToken.None); actions.Add("Nested frame exited"); }); disp.Post(() => { actions.Add("ExitAllFrames"); disp.ExitAllFrames(); }); disp.MainLoop(CancellationToken.None); Assert.Equal(new[] { "Nested frame", "ExitAllFrames", "Nested frame exited" }, actions); actions.Clear(); var secondLoop = new CancellationTokenSource(); disp.Post(() => { actions.Add("Callback after exit"); secondLoop.Cancel(); }); disp.MainLoop(secondLoop.Token); Assert.Equal(new[] { "Callback after exit" }, actions); } } [Fact] public void ShutdownShouldExitAllFramesAndNotAllowNewFrames() { using (new DispatcherServices(new SimpleControlledDispatcherImpl())) { var actions = new List(); var disp = Dispatcher.UIThread; disp.Post(() => { actions.Add("Nested frame"); Dispatcher.UIThread.MainLoop(CancellationToken.None); actions.Add("Nested frame exited"); }); disp.Post(() => { actions.Add("Shutdown"); disp.BeginInvokeShutdown(DispatcherPriority.Normal); }); disp.Post(() => { actions.Add("Nested frame after shutdown"); // This should exit immediately and not run any jobs Dispatcher.UIThread.MainLoop(CancellationToken.None); actions.Add("Nested frame after shutdown exited"); }); var criticalFrameAfterShutdown = new DispatcherFrame(false); disp.Post(() => { actions.Add("Critical frame after shutdown"); Dispatcher.UIThread.PushFrame(criticalFrameAfterShutdown); actions.Add("Critical frame after shutdown exited"); }); disp.Post(() => { actions.Add("Stop critical frame"); criticalFrameAfterShutdown.Continue = false; }); disp.MainLoop(CancellationToken.None); Assert.Equal(new[] { "Nested frame", "Shutdown", // Normal nested frames are supposed to exit immediately "Nested frame after shutdown", "Nested frame after shutdown exited", // if frame is configured to not answer dispatcher requests, it should be allowed to run "Critical frame after shutdown", "Stop critical frame", "Critical frame after shutdown exited", // After 3-rd level frames have exited, the normal nested frame exits too "Nested frame exited" }, actions); actions.Clear(); disp.Post(()=>actions.Add("Frame after shutdown finished")); Assert.Throws(() => disp.MainLoop(CancellationToken.None)); Assert.Empty(actions); } } class WaitHelper : SynchronizationContext, NonPumpingLockHelper.IHelperImpl { public int WaitCount; public override int Wait(IntPtr[] waitHandles, bool waitAll, int millisecondsTimeout) { WaitCount++; return base.Wait(waitHandles, waitAll, millisecondsTimeout); } } [Fact] public void DisableProcessingShouldStopProcessing() { using (new DispatcherServices(new SimpleControlledDispatcherImpl())) { var helper = new WaitHelper(); AvaloniaLocator.CurrentMutable.Bind().ToConstant(helper); using (Dispatcher.UIThread.DisableProcessing()) { Assert.True(SynchronizationContext.Current is NonPumpingSyncContext); Assert.Throws(() => Dispatcher.UIThread.MainLoop(CancellationToken.None)); Assert.Throws(() => Dispatcher.UIThread.RunJobs()); } var avaloniaContext = new AvaloniaSynchronizationContext(Dispatcher.UIThread, DispatcherPriority.Default, true); SynchronizationContext.SetSynchronizationContext(avaloniaContext); var waitHandle = new ManualResetEvent(true); helper.WaitCount = 0; waitHandle.WaitOne(100); Assert.Equal(0, helper.WaitCount); using (Dispatcher.UIThread.DisableProcessing()) { Assert.Equal(avaloniaContext, SynchronizationContext.Current); waitHandle.WaitOne(100); Assert.Equal(1, helper.WaitCount); } } } [Fact] public void DispatcherOperationsHaveContextWithProperPriority() { using (new DispatcherServices(new SimpleControlledDispatcherImpl())) { SynchronizationContext.SetSynchronizationContext(null); var disp = Dispatcher.UIThread; var priorities = new List(); void DumpCurrentPriority() => priorities.Add(((AvaloniaSynchronizationContext)SynchronizationContext.Current!).Priority); disp.Post(DumpCurrentPriority, DispatcherPriority.Normal); disp.Post(DumpCurrentPriority, DispatcherPriority.Loaded); disp.Post(DumpCurrentPriority, DispatcherPriority.Input); disp.Post(() => { DumpCurrentPriority(); disp.ExitAllFrames(); }, DispatcherPriority.Background); disp.MainLoop(CancellationToken.None); disp.Invoke(DumpCurrentPriority, DispatcherPriority.Send); disp.Invoke(() => { DumpCurrentPriority(); return 1; }, DispatcherPriority.Send); Assert.Equal( new[] { DispatcherPriority.Normal, DispatcherPriority.Loaded, DispatcherPriority.Input, DispatcherPriority.Background, DispatcherPriority.Send, DispatcherPriority.Send, }, priorities); } } [Fact] [SuppressMessage("Usage", "xUnit1031:Do not use blocking task operations in test method", Justification = "Tests the dispatcher itself")] public void DispatcherInvokeAsyncUnwrapsTasks() { int asyncMethodStage = 0; async Task AsyncMethod() { asyncMethodStage = 1; await Task.Delay(200); asyncMethodStage = 2; } async Task AsyncMethodWithResult() { await Task.Delay(100); return 1; } async Task Test() { await Dispatcher.UIThread.InvokeAsync(AsyncMethod); Assert.Equal(2, asyncMethodStage); Assert.Equal(1, await Dispatcher.UIThread.InvokeAsync(AsyncMethodWithResult)); asyncMethodStage = 0; await Dispatcher.UIThread.InvokeAsync(AsyncMethod, DispatcherPriority.Default); Assert.Equal(2, asyncMethodStage); Assert.Equal(1, await Dispatcher.UIThread.InvokeAsync(AsyncMethodWithResult, DispatcherPriority.Default)); Dispatcher.UIThread.ExitAllFrames(); } using (new DispatcherServices(new ManagedDispatcherImpl(null))) { var t = Test(); var cts = new CancellationTokenSource(); Task.Delay(3000).ContinueWith(_ => cts.Cancel()); Dispatcher.UIThread.MainLoop(cts.Token); Assert.True(t.IsCompletedSuccessfully); t.GetAwaiter().GetResult(); } } [Fact] public async Task DispatcherResumeContinuesOnUIThread() { using var services = new DispatcherServices(new SimpleControlledDispatcherImpl()); var tokenSource = new CancellationTokenSource(); var workload = Dispatcher.UIThread.InvokeAsync( async () => { Assert.True(Dispatcher.UIThread.CheckAccess()); await Task.Delay(1).ConfigureAwait(false); Assert.False(Dispatcher.UIThread.CheckAccess()); await Dispatcher.UIThread.Resume(); Assert.True(Dispatcher.UIThread.CheckAccess()); tokenSource.Cancel(); }); Dispatcher.UIThread.MainLoop(tokenSource.Token); } [Fact] public async Task DispatcherYieldContinuesOnUIThread() { using var services = new DispatcherServices(new SimpleControlledDispatcherImpl()); var tokenSource = new CancellationTokenSource(); var workload = Dispatcher.UIThread.InvokeAsync( async () => { Assert.True(Dispatcher.UIThread.CheckAccess()); await Dispatcher.Yield(); Assert.True(Dispatcher.UIThread.CheckAccess()); tokenSource.Cancel(); }); Dispatcher.UIThread.MainLoop(tokenSource.Token); } [Fact] public async Task DispatcherYieldThrowsOnNonUIThread() { using var services = new DispatcherServices(new SimpleControlledDispatcherImpl()); var tokenSource = new CancellationTokenSource(); var workload = Dispatcher.UIThread.InvokeAsync( async () => { Assert.True(Dispatcher.UIThread.CheckAccess()); await Task.Delay(1).ConfigureAwait(false); Assert.False(Dispatcher.UIThread.CheckAccess()); await Assert.ThrowsAsync(async () => await Dispatcher.Yield()); tokenSource.Cancel(); }); Dispatcher.UIThread.MainLoop(tokenSource.Token); } [Fact] public async Task AwaitWithPriorityRunsOnUIThread() { static async Task Workload() { await Task.Delay(1).ConfigureAwait(false); Assert.False(Dispatcher.UIThread.CheckAccess()); return Thread.CurrentThread.ManagedThreadId; } using var services = new DispatcherServices(new SimpleControlledDispatcherImpl()); var tokenSource = new CancellationTokenSource(); var workload = Dispatcher.UIThread.InvokeAsync( async () => { Assert.True(Dispatcher.UIThread.CheckAccess()); Task taskWithoutResult = Workload(); await Dispatcher.UIThread.AwaitWithPriority(taskWithoutResult, DispatcherPriority.Default); Assert.True(Dispatcher.UIThread.CheckAccess()); Task taskWithResult = Workload(); await Dispatcher.UIThread.AwaitWithPriority(taskWithResult, DispatcherPriority.Default); Assert.True(Dispatcher.UIThread.CheckAccess()); tokenSource.Cancel(); }); Dispatcher.UIThread.MainLoop(tokenSource.Token); } #nullable enable private class AsyncLocalTestClass { public AsyncLocal AsyncLocalField { get; set; } = new AsyncLocal(); } [Fact] public async Task ExecutionContextIsPreservedInDispatcherInvokeAsync() { using var services = new DispatcherServices(new SimpleControlledDispatcherImpl()); var tokenSource = new CancellationTokenSource(); string? test1 = null; string? test2 = null; string? test3 = null; // All test code must run inside Task.Run to avoid interfering with the test: // 1. Prevent the execution context from being captured by MainLoop. // 2. Prevent the execution context from remaining effective when set on the same thread. var task = Task.Run(() => { var testObject = new AsyncLocalTestClass(); // Test 1: Verify Task.Run preserves the execution context. // First, test Task.Run to ensure that the preceding validation always passes, serving as a baseline for the subsequent Invoke/InvokeAsync tests. // This way, if a later test fails, we have the .NET framework's baseline behavior for reference. testObject.AsyncLocalField.Value = "Initial Value"; var task1 = Task.Run(() => { test1 = testObject.AsyncLocalField.Value; }); // Test 2: Verify Invoke preserves the execution context. testObject.AsyncLocalField.Value = "Initial Value"; Dispatcher.UIThread.Invoke(() => { test2 = testObject.AsyncLocalField.Value; }); // Test 3: Verify InvokeAsync preserves the execution context. testObject.AsyncLocalField.Value = "Initial Value"; _ = Dispatcher.UIThread.InvokeAsync(() => { test3 = testObject.AsyncLocalField.Value; }); _ = Dispatcher.UIThread.InvokeAsync(async () => { await Task.WhenAll(task1); tokenSource.Cancel(); }); }); Dispatcher.UIThread.MainLoop(tokenSource.Token); await Task.WhenAll(task); // Assertions // Task.Run: Always passes (guaranteed by the .NET runtime). Assert.Equal("Initial Value", test1); // Invoke: Always passes because the context is not changed. Assert.Equal("Initial Value", test2); // InvokeAsync: See https://github.com/AvaloniaUI/Avalonia/pull/19163 Assert.Equal("Initial Value", test3); } [Fact] public async Task ExecutionContextIsNotPreservedAmongDispatcherInvokeAsync() { using var services = new DispatcherServices(new SimpleControlledDispatcherImpl()); var tokenSource = new CancellationTokenSource(); string? test = null; // All test code must run inside Task.Run to avoid interfering with the test: // 1. Prevent the execution context from being captured by MainLoop. // 2. Prevent the execution context from remaining effective when set on the same thread. var task = Task.Run(() => { var testObject = new AsyncLocalTestClass(); // Test: Verify that InvokeAsync calls do not share execution context between each other. _ = Dispatcher.UIThread.InvokeAsync(() => { testObject.AsyncLocalField.Value = "Initial Value"; }); _ = Dispatcher.UIThread.InvokeAsync(() => { test = testObject.AsyncLocalField.Value; }); _ = Dispatcher.UIThread.InvokeAsync(() => { tokenSource.Cancel(); }); }); Dispatcher.UIThread.MainLoop(tokenSource.Token); await Task.WhenAll(task); // Assertions // The value should NOT flow between different InvokeAsync execution contexts. Assert.Null(test); } [Fact] public async Task ExecutionContextCultureInfoIsPreservedInDispatcherInvokeAsync() { using var services = new DispatcherServices(new SimpleControlledDispatcherImpl()); var tokenSource = new CancellationTokenSource(); string? test1 = null; string? test2 = null; string? test3 = null; var oldCulture = Thread.CurrentThread.CurrentCulture; // All test code must run inside Task.Run to avoid interfering with the test: // 1. Prevent the execution context from being captured by MainLoop. // 2. Prevent the execution context from remaining effective when set on the same thread. var task = Task.Run(() => { // This culture tag is Sumerian and is extremely unlikely to be set as the default on any device, // ensuring that this test will not be affected by the user's environment. Thread.CurrentThread.CurrentCulture = CultureInfo.GetCultureInfo("sux-Shaw-UM"); // Test 1: Verify Task.Run preserves the culture in the execution context. // First, test Task.Run to ensure that the preceding validation always passes, serving as a baseline for the subsequent Invoke/InvokeAsync tests. // This way, if a later test fails, we have the .NET framework's baseline behavior for reference. var task1 = Task.Run(() => { test1 = Thread.CurrentThread.CurrentCulture.Name; }); // Test 2: Verify Invoke preserves the execution context. Dispatcher.UIThread.Invoke(() => { test2 = Thread.CurrentThread.CurrentCulture.Name; }); // Test 3: Verify InvokeAsync preserves the culture in the execution context. _ = Dispatcher.UIThread.InvokeAsync(() => { test3 = Thread.CurrentThread.CurrentCulture.Name; }); _ = Dispatcher.UIThread.InvokeAsync(async () => { await Task.WhenAll(task1); tokenSource.Cancel(); }); }); try { Dispatcher.UIThread.MainLoop(tokenSource.Token); await Task.WhenAll(task); // Assertions // Task.Run: Always passes (guaranteed by the .NET runtime). Assert.Equal("sux-Shaw-UM", test1); // Invoke: Always passes because the context is not changed. Assert.Equal("sux-Shaw-UM", test2); // InvokeAsync: See https://github.com/AvaloniaUI/Avalonia/pull/19163 Assert.Equal("sux-Shaw-UM", test3); } finally { Thread.CurrentThread.CurrentCulture = oldCulture; // Ensure that this test does not have a negative impact on other tests. Assert.NotEqual("sux-Shaw-UM", oldCulture.Name); } } #nullable restore }