Skip to content

Commit 6b06217

Browse files
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>
1 parent 0431871 commit 6b06217

2 files changed

Lines changed: 69 additions & 9 deletions

File tree

src/Headless/Avalonia.Headless/HeadlessUnitTestSession.cs

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,16 @@ internal Task<TResult> DispatchCore<TResult>(Func<Task<TResult>> action, bool ca
145145
}
146146
finally
147147
{
148-
application.Dispose();
148+
try
149+
{
150+
application.Dispose();
151+
}
152+
catch (Exception ex)
153+
{
154+
// Cleanup runs before the TCS is completed, so its failure must be
155+
// reported by this work item instead of escaping the consumer loop.
156+
caught = ex;
157+
}
149158
}
150159

151160
if (caught != null)
@@ -173,8 +182,14 @@ private IDisposable EnsureSharedApplication()
173182

174183
return Disposable.Create(() =>
175184
{
176-
Dispatcher.UIThread.RunJobs();
177-
SynchronizationContext.SetSynchronizationContext(oldContext);
185+
try
186+
{
187+
Dispatcher.UIThread.RunJobs();
188+
}
189+
finally
190+
{
191+
SynchronizationContext.SetSynchronizationContext(oldContext);
192+
}
178193
});
179194
}
180195

@@ -195,12 +210,25 @@ private IDisposable EnsureIsolatedApplication()
195210

196211
return Disposable.Create(() =>
197212
{
198-
((ToolTipService?)AvaloniaLocator.Current.GetService<IToolTipService>())?.Dispose();
199-
(AvaloniaLocator.Current.GetService<FontManager>() as IDisposable)?.Dispose();
200-
Dispatcher.ResetForUnitTests();
201-
scope.Dispose();
202-
Dispatcher.ResetBeforeUnitTests();
203-
SynchronizationContext.SetSynchronizationContext(oldContext);
213+
try
214+
{
215+
((ToolTipService?)AvaloniaLocator.Current.GetService<IToolTipService>())?.Dispose();
216+
(AvaloniaLocator.Current.GetService<FontManager>() as IDisposable)?.Dispose();
217+
Dispatcher.ResetForUnitTests();
218+
}
219+
finally
220+
{
221+
// Cleanup jobs can throw, but the ambient state still belongs to this dispatch.
222+
try
223+
{
224+
scope.Dispose();
225+
}
226+
finally
227+
{
228+
Dispatcher.ResetBeforeUnitTests();
229+
SynchronizationContext.SetSynchronizationContext(oldContext);
230+
}
231+
}
204232
});
205233
}
206234

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
#if NUNIT
2+
using System;
3+
using System.Threading;
4+
using System.Threading.Tasks;
5+
using Avalonia.Threading;
6+
7+
namespace Avalonia.Headless.UnitTests;
8+
9+
public class HeadlessUnitTestSessionTests
10+
{
11+
[Test]
12+
public async Task Dispatch_Should_Report_Cleanup_Exceptions_And_Continue()
13+
{
14+
var session = HeadlessUnitTestSession.GetOrStartForAssembly(GetType().Assembly);
15+
16+
const string message = "Thrown by a dispatcher job during cleanup.";
17+
var poisonedDispatch = session.Dispatch(
18+
() => Dispatcher.UIThread.Post(() => throw new InvalidOperationException(message)),
19+
CancellationToken.None);
20+
21+
var exception = Assert.ThrowsAsync<InvalidOperationException>(async () =>
22+
await poisonedDispatch.WaitAsync(TimeSpan.FromSeconds(10)));
23+
24+
Assert.That(exception!.Message, Is.EqualTo(message));
25+
26+
var result = await session.Dispatch(() => 42, CancellationToken.None)
27+
.WaitAsync(TimeSpan.FromSeconds(10));
28+
29+
Assert.That(result, Is.EqualTo(42));
30+
}
31+
}
32+
#endif

0 commit comments

Comments
 (0)