Browse Source

Fix headless session hang when cleanup throws (#21781)

* test(headless): reproduce cleanup exception dispatch hang

A dispatcher job that throws during work-item cleanup leaves the dispatch task incomplete and prevents later work from running. The session contract requires every queued dispatch to reach a terminal state.

Add behavioral coverage that posts a throwing cleanup job, expects its exception from Dispatch, and verifies the assembly session can process the next dispatch. The shared NUnit test runs under both PerTest and PerAssembly projects.

* fix(headless): report dispatch cleanup exceptions

An exception during application cleanup escapes the work item before its completion source is settled, faults the private consumer task, and leaves current and future Dispatch calls waiting forever.

Capture cleanup failures as the work item's exception so the consumer can continue. Restore synchronization context, locator scope, and dispatcher state in finally blocks before processing later work.

---------

Co-authored-by: Julien Lebosquain <julien@lebosquain.net>
pull/21812/head
Nathan Nguyen 2 months ago
committed by GitHub
parent
commit
0b3243e9c0
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 46
      src/Headless/Avalonia.Headless/HeadlessUnitTestSession.cs
  2. 32
      tests/Avalonia.Headless.UnitTests/HeadlessUnitTestSessionTests.cs

46
src/Headless/Avalonia.Headless/HeadlessUnitTestSession.cs

@ -145,7 +145,16 @@ public sealed class HeadlessUnitTestSession : IDisposable, IAsyncDisposable
} }
finally 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) if (caught != null)
@ -173,8 +182,14 @@ public sealed class HeadlessUnitTestSession : IDisposable, IAsyncDisposable
return Disposable.Create(() => return Disposable.Create(() =>
{ {
Dispatcher.UIThread.RunJobs(); try
SynchronizationContext.SetSynchronizationContext(oldContext); {
Dispatcher.UIThread.RunJobs();
}
finally
{
SynchronizationContext.SetSynchronizationContext(oldContext);
}
}); });
} }
@ -195,12 +210,25 @@ public sealed class HeadlessUnitTestSession : IDisposable, IAsyncDisposable
return Disposable.Create(() => return Disposable.Create(() =>
{ {
((ToolTipService?)AvaloniaLocator.Current.GetService<IToolTipService>())?.Dispose(); try
(AvaloniaLocator.Current.GetService<FontManager>() as IDisposable)?.Dispose(); {
Dispatcher.ResetForUnitTests(); ((ToolTipService?)AvaloniaLocator.Current.GetService<IToolTipService>())?.Dispose();
scope.Dispose(); (AvaloniaLocator.Current.GetService<FontManager>() as IDisposable)?.Dispose();
Dispatcher.ResetBeforeUnitTests(); Dispatcher.ResetForUnitTests();
SynchronizationContext.SetSynchronizationContext(oldContext); }
finally
{
// Cleanup jobs can throw, but the ambient state still belongs to this dispatch.
try
{
scope.Dispose();
}
finally
{
Dispatcher.ResetBeforeUnitTests();
SynchronizationContext.SetSynchronizationContext(oldContext);
}
}
}); });
} }

32
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<InvalidOperationException>(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
Loading…
Cancel
Save