Skip to content

Avalonia service-provider disposal accesses UI objects from a non-UI thread #61

Open

Description

@RoccoZero

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 / ViewFactory integration resolves these objects through DI, which already owns their disposal.
  • Keep the explicit Clear() and Remove(..., dispose: true) cleanup behavior.
  • Remove the redundant direct assignment to ContentControl.Content. Setting _context.Selected = null already 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions