Repository navigation
Avalonia service-provider disposal accesses UI objects from a non-UI thread #61
Description
Disposing the service provider used by an Avalonia application can access UI-owned objects from a non-UI thread in AsyncNavigation 2.0.2.
This can occur when shutdown starts in a process-exit callback. It can also occur after disposal starts on the UI thread: if an earlier IAsyncDisposable service returns an incomplete task, Microsoft.Extensions.DependencyInjection continues disposal after an await with ConfigureAwait(false), so later synchronous Dispose() methods can run on a worker thread.
Environment
- AsyncNavigation.Avalonia 2.0.2
- Avalonia / Avalonia.Headless 12.1.3
- Microsoft.Extensions.DependencyInjection 11.0.0-rc.1.26425.128
- .NET SDK 11.0.100-rc.1.26425.128, Windows x64
Failing cleanup paths
System.InvalidOperationException:
The calling thread cannot access this object because a different thread owns it.
Avalonia.Threading.Dispatcher.VerifyAccess()
Avalonia.StyledElement.get_DataContext()
AsyncNavigation.ViewManager.DisposeView(IView view)
AsyncNavigation.ViewManager.Clear()
AsyncNavigation.ViewManager.Dispose()
Microsoft.Extensions.DependencyInjection.ServiceLookup.ServiceProviderEngineScope.DisposeAsync()
A separate inner exception comes from:
Avalonia.Threading.Dispatcher.VerifyAccess()
Avalonia.Controls.ContentControl.set_Content(Object value)
AsyncNavigation.Avalonia.ContentRegion.<Dispose>b__4_0(ContentControl control)
AsyncNavigation.WeakRegionControlAccessor<T>.ExecuteOn(Action<T> action)
AsyncNavigation.Avalonia.ContentRegion.Dispose()
AsyncNavigation.RegionManagerBase.Dispose()
Microsoft.Extensions.DependencyInjection.ServiceLookup.ServiceProviderEngineScope.DisposeAsync()
Candidate patch
The diff below targets commit 99f457b685f4315db586ce8d0d9f83cf176d9723 (the source revision shipped in 2.0.2).
- During service-provider disposal, clear cache references without disposing the views/view models again. The default
RegisterNavigation/ViewFactoryintegration resolves these objects through DI, which already owns their disposal. - Keep the explicit
Clear()andRemove(..., dispose: true)cleanup behavior. - Remove the redundant direct assignment to
ContentControl.Content. Setting_context.Selected = nullalready updates Content through the existing binding; the binding dispatches background property notifications to the UI thread.
This is an ownership-based candidate for the default DI integration, rather than a general dispatcher wrapper around host shutdown. Custom view factories or manually supplied cache entries need an ownership review before adopting the first change universally. The newer main branch also has floating-view cache logic, so this 2.0.2 diff needs adaptation there.
diff --git a/src/AsyncNavigation/ViewManager.cs b/src/AsyncNavigation/ViewManager.cs
index 9bc0309..951a1dc 100644
--- a/src/AsyncNavigation/ViewManager.cs
+++ b/src/AsyncNavigation/ViewManager.cs
@@ -24,9 +24,11 @@ internal sealed class ViewManager : IViewManager
_viewFactory = viewFactory;
}
- public void Clear()
+ public void Clear() => Clear(disposeViews: true);
+
+ private void Clear(bool disposeViews)
{
- var values = _viewCache.Values.ToArray();
+ WeakReference<IView>[] values = disposeViews ? [.. _viewCache.Values] : [];
_viewCache.Clear();
lock (_lruLock)
{
@@ -161,10 +163,7 @@ internal sealed class ViewManager : IViewManager
}
}
- public void Dispose()
- {
- Clear();
- }
+ public void Dispose() => Clear(disposeViews: false);
private static void DisposeView(IView view)
{
diff --git a/src/AsyncNavigation.Avalonia/ContentRegion.cs b/src/AsyncNavigation.Avalonia/ContentRegion.cs
index 3c2a8ea..20bde5d 100644
--- a/src/AsyncNavigation.Avalonia/ContentRegion.cs
+++ b/src/AsyncNavigation.Avalonia/ContentRegion.cs
@@ -39,10 +39,6 @@ public class ContentRegion : RegionBase<ContentRegion, ContentControl>
{
base.Dispose();
_context.Selected = null;
- RegionControlAccessor.ExecuteOn(control =>
- {
- control.Content = null;
- });
}
public override Task ProcessActivateAsync(NavigationContext navigationContext)Validation
In an isolated Avalonia Headless reproduction using the original and patched 2.0.2 classes, the original version reproduced both UI-thread exceptions when an earlier async service delayed provider disposal. The patched version passed 16 checks, including:
- asynchronous and synchronous DI disposal;
- DI-owned view model disposal exactly once;
- Content clearing through the existing Selected binding after UI dispatcher jobs run;
- repeated cache-manager disposal;
- unchanged explicit Clear and Remove(dispose: true) cleanup.
The patch also passes git apply --check against the 2.0.2 source files. No application shutdown changes or exception suppression were required for this isolated verification.
The NativeAOT binding issue is tracked separately in #60.
Activity