Browse Source

Implement Dispatcher.UnhandledException and Dispatcher.UnhandledExceptionFilter (#14432)

* Fix AvaloniaSynchronizationContext always using UIThread

* Fix DispatcherOperation TaskSource initialization which caused some exception to be swallowed

* Implement Dispatcher.UnhandledException and Dispatcher.UnhandledExceptionFilter

* Add new internal Dispatcher.Send method without async semantics, use it in SyncContext

* Add tests, partially ported from WPF

* Make Input events go through Dispatcher.UIThread.Send

* Make Dispatcher.ExceptionDataKey internal, so it can be used in XPF

* Some Dispatcher.Send fixes

* Fix Headless tests after SynchronizationContext changes (it relied on global UIThread)

* Fix Send InvokeImpl usage

* Do not wrap AvaloniaSynchronizationContext.Ensure in the Send

---------

Co-authored-by: Nikita Tsukanov <[email protected]>
Max Katz 1 year ago
parent
commit
1ad5107654

+ 13 - 7
src/Avalonia.Base/Input/MouseDevice.cs

@@ -194,13 +194,19 @@ namespace Avalonia.Input
                 var e = new PointerReleasedEventArgs(source, _pointer, (Visual)root, p, timestamp, props, inputModifiers,
                     _lastMouseDownButton);
 
-                if (_pointer.CapturedGestureRecognizer is GestureRecognizer gestureRecognizer)
-                    gestureRecognizer.PointerReleasedInternal(e);
-                else
-                    source?.RaiseEvent(e);
-                _pointer.Capture(null);
-                _pointer.CaptureGestureRecognizer(null);
-                _lastMouseDownButton = default;
+                try
+                {
+                    if (_pointer.CapturedGestureRecognizer is GestureRecognizer gestureRecognizer)
+                        gestureRecognizer.PointerReleasedInternal(e);
+                    else
+                        source?.RaiseEvent(e);
+                }
+                finally
+                {
+                    _pointer.Capture(null);
+                    _pointer.CaptureGestureRecognizer(null);
+                    _lastMouseDownButton = default;
+                }
                 return e.Handled;
             }
 

+ 14 - 7
src/Avalonia.Base/Input/PenDevice.cs

@@ -143,13 +143,20 @@ namespace Avalonia.Input
                 var e = new PointerReleasedEventArgs(source, pointer, (Visual)root, p, timestamp, properties, inputModifiers,
                     _lastMouseDownButton);
 
-                if (pointer.CapturedGestureRecognizer is GestureRecognizer gestureRecognizer)
-                    gestureRecognizer.PointerReleasedInternal(e);
-                else
-                    source.RaiseEvent(e);
-                pointer.Capture(null);
-                pointer.CaptureGestureRecognizer(null);
-                _lastMouseDownButton = default;
+                try
+                {
+                    if (pointer.CapturedGestureRecognizer is GestureRecognizer gestureRecognizer)
+                        gestureRecognizer.PointerReleasedInternal(e);
+                    else
+                        source.RaiseEvent(e);
+                }
+                finally
+                {
+                    pointer.Capture(null);
+                    pointer.CaptureGestureRecognizer(null);
+                    _lastMouseDownButton = default;
+                }
+
                 return e.Handled;
             }
 

+ 25 - 16
src/Avalonia.Base/Threading/AvaloniaSynchronizationContext.cs

@@ -13,25 +13,33 @@ namespace Avalonia.Threading
         internal readonly DispatcherPriority Priority;
         private readonly NonPumpingLockHelper.IHelperImpl? _nonPumpingHelper =
             AvaloniaLocator.Current.GetService<NonPumpingLockHelper.IHelperImpl>();
-        
-        public AvaloniaSynchronizationContext():  this(Thread.CurrentThread.GetApartmentState() == ApartmentState.STA)
-        {
-            
-        }
-        
+        private readonly Dispatcher _dispatcher;
+
         // This constructor is here to enforce STA behavior for unit tests
-        internal AvaloniaSynchronizationContext(bool isStaThread)
+        internal AvaloniaSynchronizationContext(Dispatcher dispatcher, DispatcherPriority priority, bool isStaThread = false)
         {
+            _dispatcher = dispatcher;
+            Priority = priority;
             if (_nonPumpingHelper != null 
                 && isStaThread)
                 SetWaitNotificationRequired();
         }
 
+        public AvaloniaSynchronizationContext()
+            : this(Dispatcher.UIThread, DispatcherPriority.Default, Thread.CurrentThread.GetApartmentState() == ApartmentState.STA)
+        {
+        }
+
         public AvaloniaSynchronizationContext(DispatcherPriority priority)
+            : this(Dispatcher.UIThread, priority, false)
         {
-            Priority = priority;
         }
-        
+
+        public AvaloniaSynchronizationContext(Dispatcher dispatcher, DispatcherPriority priority)
+            : this(dispatcher, priority, false)
+        {
+        }
+
         /// <summary>
         /// Controls if SynchronizationContext should be installed in InstallIfNeeded. Used by Designer.
         /// </summary>
@@ -53,18 +61,19 @@ namespace Avalonia.Threading
         /// <inheritdoc/>
         public override void Post(SendOrPostCallback d, object? state)
         {
-            Dispatcher.UIThread.Post(d, state, Priority);
+            _dispatcher.Post(d, state, Priority);
         }
 
         /// <inheritdoc/>
         public override void Send(SendOrPostCallback d, object? state)
         {
-            if (Dispatcher.UIThread.CheckAccess())
-                d(state);
+            if (_dispatcher.CheckAccess())
+                // Same-thread, use send priority to avoid any reentrancy.
+                _dispatcher.Send(d, state, DispatcherPriority.Send);
             else
-                Dispatcher.UIThread.InvokeAsync(() => d(state), DispatcherPriority.Send).GetAwaiter().GetResult();
+                _dispatcher.Send(d, state, Priority);
         }
-        
+
 #if !NET6_0_OR_GREATER
         [PrePrepareMethod]
 #endif
@@ -72,8 +81,8 @@ namespace Avalonia.Threading
         {
             if (
                 _nonPumpingHelper != null
-                && Dispatcher.UIThread.CheckAccess() 
-                && Dispatcher.UIThread.DisabledProcessingCount > 0)
+                && _dispatcher.CheckAccess() 
+                && _dispatcher.DisabledProcessingCount > 0)
                 return _nonPumpingHelper.Wait(waitHandles, waitAll, millisecondsTimeout);
             return base.Wait(waitHandles, waitAll, millisecondsTimeout);
         }

+ 141 - 0
src/Avalonia.Base/Threading/Dispatcher.Exceptions.cs

@@ -0,0 +1,141 @@
+using System;
+
+namespace Avalonia.Threading;
+
+public partial class Dispatcher
+{
+    internal static readonly object ExceptionDataKey = new();
+    private DispatcherUnhandledExceptionFilterEventHandler? _unhandledExceptionFilter;
+
+    // Pre-allocated arguments for exception handling.
+    // This helps avoid allocations in the handler code, a potential
+    // source of secondary exceptions (i.e. in Out-Of-Memory cases).
+    private DispatcherUnhandledExceptionEventArgs _unhandledExceptionEventArgs;
+    private DispatcherUnhandledExceptionFilterEventArgs _exceptionFilterEventArgs;
+
+    /// <summary>
+    /// Occurs when a thread exception is thrown and uncaught during execution of a delegate by way of <see cref="Invoke(System.Action)"/> or <see cref="InvokeAsync(System.Action)"/>.
+    /// </summary>
+    /// <remarks>
+    /// This event is raised when an exception that was thrown during execution of a delegate by way of <see cref="Invoke(System.Action)"/> or <see cref="InvokeAsync(System.Action)"/> is uncaught.
+    /// A handler can mark the exception as handled, which will prevent the internal exception handler from being called.
+    /// Event handlers for this event must be written with care to avoid creating secondary exceptions and to catch any that occur. It is recommended to avoid allocating memory or doing any resource intensive operations in the handler.
+    /// </remarks>
+    public event DispatcherUnhandledExceptionEventHandler? UnhandledException;
+
+    /// <summary>
+    /// Occurs when a thread exception is thrown and uncaught during execution of a delegate by way of <see cref="Invoke(System.Action)"/> or <see cref="InvokeAsync(System.Action)"/> when in the filter stage.
+    /// </summary>
+    /// <remarks>
+    /// This event is raised during the filter stage for an exception that is raised during execution of a delegate by way of <see cref="Invoke(System.Action)"/> or <see cref="InvokeAsync(System.Action)"/> and is uncaught.
+    /// The call stack is not unwound at this point (first-chance exception).
+    /// Event handlers for this event must be written with care to avoid creating secondary exceptions and to catch any that occur. It is recommended to avoid allocating memory or doing any resource intensive operations in the handler.
+    /// The <see cref="UnhandledExceptionFilter"/> event provides a means to not raise the <see cref="UnhandledException"/> event. The <see cref="UnhandledExceptionFilter"/> event is raised first,
+    /// and If <see cref="DispatcherUnhandledExceptionFilterEventArgs.RequestCatch" /> is set to false, the <see cref="UnhandledException"/> event will not be raised.
+    /// </remarks>
+    public event DispatcherUnhandledExceptionFilterEventHandler? UnhandledExceptionFilter
+    {
+        add
+        {
+            _unhandledExceptionFilter += value;
+        }
+        remove
+        {
+            _unhandledExceptionFilter -= value;
+        }
+    }
+
+    /// Exception filter returns true if exception should be caught.
+    internal bool ExceptionFilter(Exception e)
+    {
+        // see whether this dispatcher has already seen the exception.
+        // This can happen when the dispatcher is re-entered via
+        // PushFrame (or similar).
+        if (!e.Data.Contains(ExceptionDataKey))
+        {
+            // first time we've seen this exception - add data to the exception
+            e.Data.Add(ExceptionDataKey, null);
+        }
+        else
+        {
+            // we've seen this exception before - don't catch it
+            return false;
+        }
+
+        // By default, Request catch if there's anyone signed up to catch it;
+        var requestCatch = UnhandledException is not null;
+
+        // The app can hook up an ExceptionFilter to avoid catching it.
+        // ExceptionFilter will run REGARDLESS of whether there are exception handlers.
+        if (_unhandledExceptionFilter != null)
+        {
+            // The default requestCatch value that is passed in the args
+            // should be returned unchanged if filters don't set them explicitly.
+            _exceptionFilterEventArgs.Initialize(e, requestCatch);
+            var bSuccess = false;
+            try
+            {
+                _unhandledExceptionFilter(this, _exceptionFilterEventArgs);
+                bSuccess = true;
+            }
+            finally
+            {
+                if (bSuccess)
+                {
+                    requestCatch = _exceptionFilterEventArgs.RequestCatch;
+                }
+
+                // For bSuccess is false,
+                // To be in line with default behavior of structured exception handling,
+                // we would want to set requestCatch to false, however, this means one
+                // poorly programmed filter will knock out all dispatcher exception handling.
+                // If an exception filter fails, we run with whatever value is set thus far.
+            }
+        }
+
+        return requestCatch;
+    }
+
+    internal bool CatchException(Exception e)
+    {
+        var handled = false;
+
+        if (UnhandledException != null)
+        {
+            _unhandledExceptionEventArgs.Initialize(e, false);
+
+            var bSuccess = false;
+            try
+            {
+                UnhandledException(this, _unhandledExceptionEventArgs);
+                handled = _unhandledExceptionEventArgs.Handled;
+                bSuccess = true;
+            }
+            finally
+            {
+                if (!bSuccess)
+                    handled = false;
+            }
+        }
+
+        return handled;
+    }
+
+    /// Returns true, if exception was handled.
+    internal bool TryCatchWhen(Exception e)
+    {
+        if (ExceptionFilter(e))
+        {
+            if (!CatchException(e))
+            {
+                return false;
+            }
+        }
+        else
+        {
+            return false;
+        }
+
+        return true;
+    }
+}

+ 34 - 0
src/Avalonia.Base/Threading/Dispatcher.Invoke.cs

@@ -625,6 +625,40 @@ public partial class Dispatcher
         _ = action ?? throw new ArgumentNullException(nameof(action));
         InvokeAsyncImpl(new SendOrPostCallbackDispatcherOperation(this, priority, action, arg, true), CancellationToken.None);
     }
+    
+    /// <summary>
+    /// Sends an action that will be invoked on the dispatcher thread.
+    /// </summary>
+    /// <param name="action">The method.</param>
+    /// <param name="arg">The argument of method to call.</param>
+    /// <param name="priority">The priority with which to invoke the method. If null, Send is default.</param>
+    /// <remarks>
+    /// When on the same thread with Send priority, callback is executed immediately, without changing synchronization context.
+    /// </remarks>
+    internal void Send(SendOrPostCallback action, object? arg, DispatcherPriority? priority = null)
+    {
+        _ = action ?? throw new ArgumentNullException(nameof(action));
+        priority ??= DispatcherPriority.Send;
+
+        if (priority == DispatcherPriority.Send && CheckAccess())
+        {
+            try
+            {
+                action(arg);
+            }
+            catch (Exception ex) when (ExceptionFilter(ex))
+            {
+                if (!CatchException(ex))
+                    throw;
+            }
+        }
+        else
+        {
+            InvokeImpl(new SendOrPostCallbackDispatcherOperation(this, priority.Value, action, arg, true),
+                CancellationToken.None,
+                default);
+        }
+    }
 
     /// <summary>
     /// Returns a task awaitable that would invoke continuation on specified dispatcher priority

+ 4 - 1
src/Avalonia.Base/Threading/Dispatcher.cs

@@ -39,6 +39,9 @@ public partial class Dispatcher : IDispatcher
             MaximumInputStarvationTimeInExplicitProcessingExplicitMode;
         if (_backgroundProcessingImpl != null)
             _backgroundProcessingImpl.ReadyForBackgroundProcessing += OnReadyForExplicitBackgroundProcessing;
+
+        _unhandledExceptionEventArgs = new DispatcherUnhandledExceptionEventArgs(this);
+        _exceptionFilterEventArgs = new DispatcherUnhandledExceptionFilterEventArgs(this);
     }
     
     public static Dispatcher UIThread => s_uiThread ??= CreateUIThreadDispatcher();
@@ -86,6 +89,6 @@ public partial class Dispatcher : IDispatcher
     {
         DispatcherPriority.Validate(priority, nameof(priority));
         var index = priority - DispatcherPriority.MinValue;
-        return _priorityContexts[index] ??= new(priority);
+        return _priorityContexts[index] ??= new(this, priority);
     }
 }

+ 19 - 0
src/Avalonia.Base/Threading/DispatcherEventArgs.cs

@@ -0,0 +1,19 @@
+using System;
+
+namespace Avalonia.Threading;
+
+/// <summary>
+/// Provides event data for Dispatcher related events.
+/// </summary>
+public abstract class DispatcherEventArgs : EventArgs
+{
+    /// <summary>
+    /// The Dispatcher associated with this event.
+    /// </summary>
+    public Dispatcher Dispatcher { get; }
+
+    internal DispatcherEventArgs(Dispatcher dispatcher)
+    {
+        Dispatcher = dispatcher;
+    }
+}

+ 13 - 4
src/Avalonia.Base/Threading/DispatcherOperation.cs

@@ -283,13 +283,17 @@ public class DispatcherOperation
         {
             lock (Dispatcher.InstanceLock)
             {
+                // Ensure TaskSource created.
+                _ = GetTaskCore();
                 Status = DispatcherOperationStatus.Completed;
                 if (TaskSource is TaskCompletionSource<object?> tcs)
                     tcs.SetException(e);
             }
 
-            if (ThrowOnUiThread)
+            if (ThrowOnUiThread && !Dispatcher.TryCatchWhen(e))
+            {
                 throw;
+            }
         }
     }
 
@@ -312,10 +316,11 @@ public class DispatcherOperation
         {
             if (Status == DispatcherOperationStatus.Aborted)
                 return s_abortedTask;
+            if (TaskSource is TaskCompletionSource<object?> tcs)
+                return tcs.Task;
             if (Status == DispatcherOperationStatus.Completed)
                 return Task.CompletedTask;
-            if (TaskSource is not TaskCompletionSource<object?> tcs)
-                TaskSource = tcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
+            TaskSource = tcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
             return tcs.Task;
         }
     }
@@ -401,13 +406,17 @@ internal sealed class SendOrPostCallbackDispatcherOperation : DispatcherOperatio
         {
             lock (Dispatcher.InstanceLock)
             {
+                // Ensure TaskSource created.
+                _ = GetTaskCore();
                 Status = DispatcherOperationStatus.Completed;
                 if (TaskSource is TaskCompletionSource<object?> tcs)
                     tcs.SetException(e);
             }
 
-            if (ThrowOnUiThread)
+            if (ThrowOnUiThread && !Dispatcher.TryCatchWhen(e))
+            {
                 throw;
+            }
         }
     }
 }

+ 55 - 0
src/Avalonia.Base/Threading/DispatcherUnhandledExceptionEventArgs.cs

@@ -0,0 +1,55 @@
+using System;
+using System.Diagnostics;
+using Avalonia.Interactivity;
+
+namespace Avalonia.Threading;
+
+/// <summary>
+/// Represents the method that will handle the <see cref="Dispatcher.UnhandledException"/> event.
+/// </summary>
+public delegate void DispatcherUnhandledExceptionEventHandler(object sender, DispatcherUnhandledExceptionEventArgs e);
+
+/// <summary>
+/// Provides data for the <see cref="Dispatcher.UnhandledException"/> event.
+/// </summary>
+public sealed class DispatcherUnhandledExceptionEventArgs : DispatcherEventArgs
+{
+    private Exception _exception;
+    private bool _handled;
+
+    internal DispatcherUnhandledExceptionEventArgs(Dispatcher dispatcher) : base(dispatcher)
+    {
+        _exception = null!;
+    }
+
+    /// <summary>
+    /// Gets the exception that was raised when executing code by way of the dispatcher.
+    /// </summary>
+    public Exception Exception => _exception;
+    
+    /// <summary>
+    /// Gets or sets whether the exception event has been handled.
+    /// </summary>
+    public bool Handled
+    {
+        get
+        {
+            return _handled;
+        }
+        set
+        {
+            // Only allow to be set true.
+            if (value)
+            {
+                _handled = value;
+            }
+        }
+    }
+
+    internal void Initialize(Exception exception, bool handled)
+    {
+        Debug.Assert(exception != null);
+        _exception = exception;
+        _handled = handled;
+    }
+}

+ 65 - 0
src/Avalonia.Base/Threading/DispatcherUnhandledExceptionFilterEventArgs.cs

@@ -0,0 +1,65 @@
+using System;
+using System.Diagnostics;
+
+namespace Avalonia.Threading;
+
+/// <summary>
+/// Provides data for the <see cref="Dispatcher.UnhandledExceptionFilter"/> event.
+/// </summary>
+public delegate void DispatcherUnhandledExceptionFilterEventHandler(object sender,
+    DispatcherUnhandledExceptionFilterEventArgs e);
+
+/// <summary>
+/// Represents the method that will handle the <see cref="Dispatcher.UnhandledExceptionFilter"/> event.
+/// </summary>
+public sealed class DispatcherUnhandledExceptionFilterEventArgs : DispatcherEventArgs
+{
+    private Exception? _exception;
+    private bool _requestCatch;
+
+    internal DispatcherUnhandledExceptionFilterEventArgs(Dispatcher dispatcher)
+        : base(dispatcher)
+    {
+    }
+
+    /// <summary>
+    /// Gets the exception that was raised when executing code by way of the dispatcher.
+    /// </summary>
+    public Exception Exception => _exception!;
+
+    /// <summary>
+    /// Gets or sets whether the exception should be caught and the event handlers called..
+    /// </summary>
+    /// <remarks>
+    ///     A filter handler can set this property to false to request that
+    ///     the exception not be caught, to avoid the callstack getting
+    ///     unwound up to the Dispatcher.
+    ///     <p/>
+    ///     A previous handler in the event multicast might have already set this 
+    ///     property to false, signalling that the exception will not be caught.
+    ///     We let the "don't catch" behavior override all others because
+    ///     it most likely means a debugging scenario.
+    /// </remarks>
+    public bool RequestCatch
+    {
+        get
+        {
+            return _requestCatch;
+        }
+        set
+        {
+            // Only allow to be set false.
+            if (!value)
+            {
+                _requestCatch = value;
+            }
+        }
+    }
+
+    internal void Initialize(Exception exception, bool requestCatch)
+    {
+        Debug.Assert(exception != null);
+        _exception = exception;
+        _requestCatch = requestCatch;
+    }
+}

+ 8 - 4
src/Avalonia.Controls/TopLevel.cs

@@ -825,12 +825,16 @@ namespace Avalonia.Controls
         {
             if (PlatformImpl != null)
             {
-                if (e is RawPointerEventArgs pointerArgs)
+                Dispatcher.UIThread.Send(static state =>
                 {
-                    pointerArgs.InputHitTestResult = this.InputHitTest(pointerArgs.Position);
-                }
+                    var (topLevel, e) = (ValueTuple<TopLevel, RawInputEventArgs>)state!;
+                    if (e is RawPointerEventArgs pointerArgs)
+                    {
+                        pointerArgs.InputHitTestResult = topLevel.InputHitTest(pointerArgs.Position);
+                    }
 
-                _inputManager?.ProcessInput(e);
+                    topLevel._inputManager?.ProcessInput(e);
+                }, (this, e));
             }
             else
             {

+ 15 - 10
src/Headless/Avalonia.Headless/HeadlessUnitTestSession.cs

@@ -86,19 +86,23 @@ public sealed class HeadlessUnitTestSession : IDisposable
                 using var application = EnsureApplication();
 
                 var task = action();
-                task.ContinueWith((_, s) => ((CancellationTokenSource)s!).Cancel(), cts,
-                    TaskScheduler.FromCurrentSynchronizationContext());
-
-                if (cts.IsCancellationRequested)
+                if (task.Status != TaskStatus.RanToCompletion)
                 {
-                    tcs.TrySetCanceled(cts.Token);
-                    return;
+                    task.ContinueWith((_, s) =>
+                            ((CancellationTokenSource)s!).Cancel(), cts,
+                        TaskScheduler.FromCurrentSynchronizationContext());
+
+                    if (cts.IsCancellationRequested)
+                    {
+                        tcs.TrySetCanceled(cts.Token);
+                        return;
+                    }
+
+                    var frame = new DispatcherFrame();
+                    using var innerCts = cts.Token.Register(() => frame.Continue = false, true);
+                    Dispatcher.UIThread.PushFrame(frame);
                 }
 
-                var frame = new DispatcherFrame();
-                using var innerCts = cts.Token.Register(() => frame.Continue = false, true);
-                Dispatcher.UIThread.PushFrame(frame);
-
                 var result = task.GetAwaiter().GetResult();
                 tcs.TrySetResult(result);
             }
@@ -128,6 +132,7 @@ public sealed class HeadlessUnitTestSession : IDisposable
         {
             scope.Dispose();
             Dispatcher.ResetForUnitTests();
+            SynchronizationContext.SetSynchronizationContext(null);
         });
     }
 

+ 387 - 0
tests/Avalonia.Base.UnitTests/DispatcherTests.Exception.cs

@@ -0,0 +1,387 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Avalonia.Controls.Platform;
+using Avalonia.Threading;
+using Xunit;
+
+namespace Avalonia.Base.UnitTests;
+
+// Some of these exceptions are based from https://github.com/dotnet/wpf-test/blob/05797008bb4975ceeb71be36c47f01688f535d53/src/Test/ElementServices/FeatureTests/Untrusted/Dispatcher/UnhandledExceptionTest.cs#L30
+public partial class DispatcherTests
+{
+    private const string ExpectedExceptionText = "Exception thrown inside Dispatcher.Invoke / Dispatcher.BeginInvoke.";
+
+    private int _numberOfHandlerOnUnhandledEventInvoked;
+    private int _numberOfHandlerOnUnhandledEventFilterInvoked;
+
+    public DispatcherTests()
+    {
+        _numberOfHandlerOnUnhandledEventInvoked = 0;
+        _numberOfHandlerOnUnhandledEventFilterInvoked = 0;
+    }
+
+    [Fact]
+    public void DispatcherHandlesExceptionWithPost()
+    {
+        var impl = new ManagedDispatcherImpl(null);
+        var disp = new Dispatcher(impl);
+
+        var handled = false;
+        var executed = false;
+        disp.UnhandledException += (sender, args) =>
+        {
+            handled = true;
+            args.Handled = true;
+        };
+        disp.Post(() => ThrowAnException());
+        disp.Post(() => executed = true);
+
+        disp.RunJobs();
+        
+        Assert.True(handled);
+        Assert.True(executed);
+    }
+
+    [Fact]
+    public void SyncContextExceptionCanBeHandledWithPost()
+    {
+        var impl = new ManagedDispatcherImpl(null);
+        var disp = new Dispatcher(impl);
+
+        var syncContext = disp.GetContextWithPriority(DispatcherPriority.Background);
+
+        var handled = false;
+        var executed = false;
+        disp.UnhandledException += (sender, args) =>
+        {
+            handled = true;
+            args.Handled = true;
+        };
+
+        syncContext.Post(_ => ThrowAnException(), null);
+        syncContext.Post(_ => executed = true, null);
+
+        disp.RunJobs();
+
+        Assert.True(handled);
+        Assert.True(executed);
+    }
+
+    [Fact]
+    public void CanRemoveDispatcherExceptionHandler()
+    {
+        var impl = new ManagedDispatcherImpl(null);
+        var dispatcher = new Dispatcher(impl);
+        var caughtCorrectException = false;
+
+        dispatcher.UnhandledExceptionFilter +=
+            HandlerOnUnhandledExceptionFilterRequestCatch;
+        dispatcher.UnhandledException +=
+            HandlerOnUnhandledExceptionNotHandled;
+
+        dispatcher.UnhandledExceptionFilter -=
+            HandlerOnUnhandledExceptionFilterRequestCatch;
+        dispatcher.UnhandledException -=
+            HandlerOnUnhandledExceptionNotHandled;
+
+        try
+        {
+            dispatcher.Post(ThrowAnException, DispatcherPriority.Normal);
+            dispatcher.RunJobs();
+        }
+        catch (Exception e)
+        {
+            caughtCorrectException = e.Message == ExpectedExceptionText;
+        }
+        finally
+        {
+            Verification(caughtCorrectException, 0, 0);
+        }
+    }
+
+    [Fact]
+    public void CanHandleExceptionWithUnhandledException()
+    {
+        var impl = new ManagedDispatcherImpl(null);
+        var dispatcher = new Dispatcher(impl);
+        
+        dispatcher.UnhandledExceptionFilter +=
+            HandlerOnUnhandledExceptionFilterRequestCatch;
+        
+        dispatcher.UnhandledException +=
+            HandlerOnUnhandledExceptionHandled;
+        var caughtCorrectException = true;
+        try
+        {
+            dispatcher.Post(ThrowAnException, DispatcherPriority.Normal);
+            dispatcher.RunJobs();
+        }
+        catch (Exception)
+        {
+            // should be no exception here.
+            caughtCorrectException = false;
+        }
+        finally
+        {
+            Verification(caughtCorrectException, 1, 1);
+        }
+    }
+
+    [Fact]
+    public void InvokeMethodDoesntTriggerUnhandledException()
+    {
+        var impl = new ManagedDispatcherImpl(null);
+        var dispatcher = new Dispatcher(impl);
+        
+        dispatcher.UnhandledExceptionFilter +=
+            HandlerOnUnhandledExceptionFilterRequestCatch;
+        
+        dispatcher.UnhandledException +=
+            HandlerOnUnhandledExceptionHandled;
+        var caughtCorrectException = false;
+        try
+        {
+            // Since both Invoke and InvokeAsync can throw exception, there is no need to pass them to the UnhandledException.
+            dispatcher.Invoke(ThrowAnException, DispatcherPriority.Normal);
+            dispatcher.RunJobs();
+        }
+        catch (Exception e)
+        {
+            // should be no exception here.
+            caughtCorrectException = e.Message == ExpectedExceptionText;
+        }
+        finally
+        {
+            Verification(caughtCorrectException, 0, 0);
+        }
+    }
+
+    [Fact]
+    public void InvokeAsyncMethodDoesntTriggerUnhandledException()
+    {
+        var impl = new ManagedDispatcherImpl(null);
+        var dispatcher = new Dispatcher(impl);
+        
+        dispatcher.UnhandledExceptionFilter +=
+            HandlerOnUnhandledExceptionFilterRequestCatch;
+        
+        dispatcher.UnhandledException +=
+            HandlerOnUnhandledExceptionHandled;
+        var caughtCorrectException = false;
+        try
+        {
+            // Since both Invoke and InvokeAsync can throw exception, there is no need to pass them to the UnhandledException.
+            var op = dispatcher.InvokeAsync(ThrowAnException, DispatcherPriority.Normal);
+            op.Wait();
+            dispatcher.RunJobs();
+        }
+        catch (Exception e)
+        {
+            // should be no exception here.
+            caughtCorrectException = e.Message == ExpectedExceptionText;
+        }
+        finally
+        {
+            Verification(caughtCorrectException, 0, 0);
+        }
+    }
+
+    [Fact]
+    public void CanRethrowExceptionWithUnhandledException()
+    {
+        var impl = new ManagedDispatcherImpl(null);
+        var dispatcher = new Dispatcher(impl);
+        
+        dispatcher.UnhandledExceptionFilter +=
+            HandlerOnUnhandledExceptionFilterRequestCatch;
+        
+        dispatcher.UnhandledException +=
+            HandlerOnUnhandledExceptionNotHandled;
+        var caughtCorrectException = false;
+        try
+        {
+            dispatcher.Post(ThrowAnException, DispatcherPriority.Normal);
+            dispatcher.RunJobs();
+        }
+        catch (Exception e)
+        {
+            caughtCorrectException = e.Message == ExpectedExceptionText;
+        }
+        finally
+        {
+            Verification(caughtCorrectException, 1, 1);
+        }
+    }
+
+    [Fact]
+    public void MultipleUnhandledExceptionFilterCannotResetRequestCatchFlag()
+    {
+        var impl = new ManagedDispatcherImpl(null);
+        var dispatcher = new Dispatcher(impl);
+        
+        dispatcher.UnhandledExceptionFilter +=
+            HandlerOnUnhandledExceptionFilterNotRequestCatch;
+        dispatcher.UnhandledExceptionFilter +=
+            HandlerOnUnhandledExceptionFilterRequestCatch;
+        
+        dispatcher.UnhandledException +=
+            HandlerOnUnhandledExceptionNotHandled;
+        dispatcher.UnhandledException +=
+            HandlerOnUnhandledExceptionHandled;
+        var caughtCorrectException = false;
+        try
+        {
+            dispatcher.Post(ThrowAnException, DispatcherPriority.Normal);
+            dispatcher.RunJobs();
+        }
+        catch (Exception e)
+        {
+            caughtCorrectException = e.Message == ExpectedExceptionText;
+        }
+        finally
+        {
+            Verification(caughtCorrectException, 0, 2);
+        }
+    }
+
+    [Fact]
+    public void MultipleUnhandledExceptionCannotResetHandleFlag()
+    {
+        var impl = new ManagedDispatcherImpl(null);
+        var dispatcher = new Dispatcher(impl);
+        
+        dispatcher.UnhandledExceptionFilter +=
+            HandlerOnUnhandledExceptionFilterRequestCatch;
+        
+        dispatcher.UnhandledException +=
+            HandlerOnUnhandledExceptionHandled;
+        dispatcher.UnhandledException +=
+            HandlerOnUnhandledExceptionNotHandled;
+        var caughtCorrectException = true;
+        
+        try
+        {
+            dispatcher.Post(ThrowAnException, DispatcherPriority.Normal);
+            dispatcher.RunJobs();
+        }
+        catch (Exception e)
+        {
+            // should be no exception here.
+            caughtCorrectException = false;
+        }
+        finally
+        {
+            Verification(caughtCorrectException, 1, 1);
+        }
+    }
+
+    [Fact]
+    public void CanPushFrameAndShutdownDispatcherFromUnhandledException()
+    {
+        var impl = new ManagedDispatcherImpl(null);
+        var dispatcher = new Dispatcher(impl);
+        
+        dispatcher.UnhandledExceptionFilter +=
+            HandlerOnUnhandledExceptionFilterNotRequestCatchPushFrame;
+        
+        dispatcher.UnhandledException +=
+            HandlerOnUnhandledExceptionHandledPushFrame;
+        var caughtCorrectException = false;
+        try
+        {
+            dispatcher.Post(ThrowAnException, DispatcherPriority.Normal);
+            dispatcher.RunJobs();
+        }
+        catch (Exception e)
+        {
+            caughtCorrectException = e.Message == ExpectedExceptionText;
+        }
+        finally
+        {
+            Verification(caughtCorrectException, 0, 1);
+        }
+    }
+    
+    private void Verification(bool caughtCorrectException, int numberOfHandlerOnUnhandledEventShouldInvoke,
+        int numberOfHandlerOnUnhandledEventFilterShouldInvoke)
+    {
+        Assert.True(
+            _numberOfHandlerOnUnhandledEventInvoked >= numberOfHandlerOnUnhandledEventShouldInvoke,
+            "Number of handler invoked on UnhandledException is invalid");
+
+        Assert.True(
+            _numberOfHandlerOnUnhandledEventFilterInvoked >= numberOfHandlerOnUnhandledEventFilterShouldInvoke,
+            "Number of handler invoked on UnhandledExceptionFilter is invalid");
+
+        Assert.True(caughtCorrectException, "Wrong exception caught.");
+    }
+
+    private void HandlerOnUnhandledExceptionFilterRequestCatch(object sender,
+        DispatcherUnhandledExceptionFilterEventArgs args)
+    {
+        args.RequestCatch = true;
+
+        _numberOfHandlerOnUnhandledEventFilterInvoked += 1;
+        Assert.Equal(ExpectedExceptionText, args.Exception.Message);
+    }
+
+    private void HandlerOnUnhandledExceptionFilterNotRequestCatchPushFrame(object sender,
+        DispatcherUnhandledExceptionFilterEventArgs args)
+    {
+        HandlerOnUnhandledExceptionFilterNotRequestCatch(sender, args);
+        var frame = new DispatcherFrame();
+        args.Dispatcher.InvokeAsync(() => frame.Continue = false, DispatcherPriority.Background);
+        args.Dispatcher.PushFrame(frame);
+    }
+
+    private void HandlerOnUnhandledExceptionFilterNotRequestCatch(object sender,
+        DispatcherUnhandledExceptionFilterEventArgs args)
+    {
+        args.RequestCatch = false;
+        _numberOfHandlerOnUnhandledEventFilterInvoked += 1;
+
+        Assert.Equal(ExpectedExceptionText, args.Exception.Message);
+    }
+
+    private void HandlerOnUnhandledExceptionHandledPushFrame(object sender, DispatcherUnhandledExceptionEventArgs args)
+    {
+        Assert.Equal(ExpectedExceptionText, args.Exception.Message);
+        Assert.False(_numberOfHandlerOnUnhandledEventFilterInvoked == 0,
+            "UnhandledExceptionFilter should be invoked before UnhandledException.");
+
+        args.Handled = true;
+        _numberOfHandlerOnUnhandledEventInvoked += 1;
+
+        var dispatcher = args.Dispatcher;
+        var frame = new DispatcherFrame();
+        dispatcher.BeginInvokeShutdown(DispatcherPriority.Background);
+        dispatcher.PushFrame(frame);
+    }
+
+    private void HandlerOnUnhandledExceptionHandled(object sender, DispatcherUnhandledExceptionEventArgs args)
+    {
+        Assert.Equal(ExpectedExceptionText, args.Exception.Message);
+        Assert.False(_numberOfHandlerOnUnhandledEventFilterInvoked == 0,
+            "UnhandledExceptionFilter should be invoked before UnhandledException.");
+
+        args.Handled = true;
+        _numberOfHandlerOnUnhandledEventInvoked += 1;
+    }
+
+    private void HandlerOnUnhandledExceptionNotHandled(object sender, DispatcherUnhandledExceptionEventArgs args)
+    {
+        Assert.Equal(ExpectedExceptionText, args.Exception.Message);
+        Assert.False(_numberOfHandlerOnUnhandledEventFilterInvoked == 0,
+            "UnhandledExceptionFilter should be invoked before UnhandledException.");
+
+        args.Handled = false;
+        _numberOfHandlerOnUnhandledEventInvoked += 1;
+    }
+
+    private void ThrowAnException()
+    {
+        throw new Exception(ExpectedExceptionText);
+    }
+}
+

+ 2 - 2
tests/Avalonia.Base.UnitTests/DispatcherTests.cs

@@ -10,7 +10,7 @@ using Avalonia.Utilities;
 using Xunit;
 namespace Avalonia.Base.UnitTests;
 
-public class DispatcherTests
+public partial class DispatcherTests
 {
     class SimpleDispatcherImpl : IDispatcherImpl, IDispatcherImplWithPendingInput
     {
@@ -402,7 +402,7 @@ public class DispatcherTests
                 Assert.Throws<InvalidOperationException>(() => Dispatcher.UIThread.RunJobs());
             }
 
-            var avaloniaContext = new AvaloniaSynchronizationContext(true);
+            var avaloniaContext = new AvaloniaSynchronizationContext(Dispatcher.UIThread, DispatcherPriority.Default, true);
             SynchronizationContext.SetSynchronizationContext(avaloniaContext);
 
             var waitHandle = new ManualResetEvent(true);