diff --git a/src/Headless/Avalonia.Headless/HeadlessUnitTestSession.cs b/src/Headless/Avalonia.Headless/HeadlessUnitTestSession.cs index 5613244461..f215d867f0 100644 --- a/src/Headless/Avalonia.Headless/HeadlessUnitTestSession.cs +++ b/src/Headless/Avalonia.Headless/HeadlessUnitTestSession.cs @@ -145,7 +145,16 @@ public sealed class HeadlessUnitTestSession : IDisposable, IAsyncDisposable } finally { - application.Dispose(); + try + { + application.Dispose(); + } + catch (Exception ex) + { + // Cleanup runs before the TCS is completed, so its failure must be + // reported by this work item instead of escaping the consumer loop. + caught = ex; + } } if (caught != null) @@ -173,8 +182,14 @@ public sealed class HeadlessUnitTestSession : IDisposable, IAsyncDisposable return Disposable.Create(() => { - Dispatcher.UIThread.RunJobs(); - SynchronizationContext.SetSynchronizationContext(oldContext); + try + { + Dispatcher.UIThread.RunJobs(); + } + finally + { + SynchronizationContext.SetSynchronizationContext(oldContext); + } }); } @@ -195,12 +210,25 @@ public sealed class HeadlessUnitTestSession : IDisposable, IAsyncDisposable return Disposable.Create(() => { - ((ToolTipService?)AvaloniaLocator.Current.GetService())?.Dispose(); - (AvaloniaLocator.Current.GetService() as IDisposable)?.Dispose(); - Dispatcher.ResetForUnitTests(); - scope.Dispose(); - Dispatcher.ResetBeforeUnitTests(); - SynchronizationContext.SetSynchronizationContext(oldContext); + try + { + ((ToolTipService?)AvaloniaLocator.Current.GetService())?.Dispose(); + (AvaloniaLocator.Current.GetService() as IDisposable)?.Dispose(); + Dispatcher.ResetForUnitTests(); + } + finally + { + // Cleanup jobs can throw, but the ambient state still belongs to this dispatch. + try + { + scope.Dispose(); + } + finally + { + Dispatcher.ResetBeforeUnitTests(); + SynchronizationContext.SetSynchronizationContext(oldContext); + } + } }); } diff --git a/tests/Avalonia.Headless.UnitTests/HeadlessUnitTestSessionTests.cs b/tests/Avalonia.Headless.UnitTests/HeadlessUnitTestSessionTests.cs new file mode 100644 index 0000000000..7372dd639a --- /dev/null +++ b/tests/Avalonia.Headless.UnitTests/HeadlessUnitTestSessionTests.cs @@ -0,0 +1,32 @@ +#if NUNIT +using System; +using System.Threading; +using System.Threading.Tasks; +using Avalonia.Threading; + +namespace Avalonia.Headless.UnitTests; + +public class HeadlessUnitTestSessionTests +{ + [Test] + public async Task Dispatch_Should_Report_Cleanup_Exceptions_And_Continue() + { + var session = HeadlessUnitTestSession.GetOrStartForAssembly(GetType().Assembly); + + const string message = "Thrown by a dispatcher job during cleanup."; + var poisonedDispatch = session.Dispatch( + () => Dispatcher.UIThread.Post(() => throw new InvalidOperationException(message)), + CancellationToken.None); + + var exception = Assert.ThrowsAsync(async () => + await poisonedDispatch.WaitAsync(TimeSpan.FromSeconds(10))); + + Assert.That(exception!.Message, Is.EqualTo(message)); + + var result = await session.Dispatch(() => 42, CancellationToken.None) + .WaitAsync(TimeSpan.FromSeconds(10)); + + Assert.That(result, Is.EqualTo(42)); + } +} +#endif