uno icon indicating copy to clipboard operation
uno copied to clipboard

Memory leak

Open ProphetLamb opened this issue 5 months ago • 9 comments

Current behavior 🐛

This is reproducible with

  • https://playground.platform.uno/#cards
  • https://gallery.platform.uno/ or any UNO app using navigation or a feedview, such as my sample.
  1. Navigate back and forth between two pages: 'Overview' and 'Grid Lines'. (Or 'Cards' and 'ViewBox' for UNO playground)
  2. Check memory usage in task manager
  3. Each navigation adds 50-100MB of Gen2 GC objects.At 4GB of allocation with 32GB of memory, the window performance degrades due to GC pressure.
Image

Memory profiling shows

  1. Controls are not disposed
  2. DependencyObjects are not disposed

In the 4th snapshot I've navigated 3 times, and we have 4 instances of our control and view.

Image

Here is the 5th snapshot with 5 instances of our control:

Image

There is no usercode logic that cross-references controls on pages.

Expected behavior 🎯

Memory is freed once no longer needed.

How to reproduce it (as minimally and precisely as possible) 🔬

Sample app: https://tableview.samples.w-ahmad.dev/ Source: https://github.com/w-ahmad/WinUI.TableView.SampleApp

Workaround 🛠️

None.

Renderer 🎨

  • [x] Skia
  • [x] Native

Affected platforms 📱💻🖥️

Desktop (Windows), Desktop (Linux), WebAssembly

Uno.Sdk version (and other relevant versions) 📦

  • 6.2.0-dev.77
  • 6.2.0-dev.26
  • 6.1.23
  • 6.0.146

.NET SDK: Version: 9.0.300 Commit: 15606fe0a8 Workload version: 9.0.300-manifests.ac577b4d MSBuild version: 17.14.5+edd3bbf37

IDE version 🧑‍💻

No response

Anything else we need to know? 💬

No response

ProphetLamb avatar Aug 06 '25 12:08 ProphetLamb

@ProphetLamb Why are you mentioning the FeedView in the title? Apparently in your repro there is no FeedView, is this with all controls your tried or only specific one?

dr1rrb avatar Aug 06 '25 14:08 dr1rrb

@ProphetLamb Why are you mentioning the FeedView in the title? Apparently in your repro there is no FeedView, is this with all controls your tried or only specific one?

The same symptom occurs on FeedView refresh: Created DependencyObjectss remain referenced. I do not provide a sample, because I can workaround the issue utilizing ObervableCollection. It is much less critical, but investigation may assist in figuring out the more critical timebomb in UNO.

ProphetLamb avatar Aug 06 '25 15:08 ProphetLamb

The following steps mitigate the issue somewhat

  • Target WinUI, it uses half the memory of the desktop target (155MB vs 310MB), therefore less memory is leaked by each navigation. -> 50% less leakage
  • Force a blocking gen2 GC immediately after navigation. (17MB vs 25MB). -> 30% less leakage.
await nav.NavigateViewAsync<TPage>(sender, data: data, cancellation: ct).ConfigureAwait(false);
GC.Collect(2, GCCollectionMode.Aggressive, blocking: true);

Without forced GC Image With forced GC Image

This might be a false positive, because it's likely reclaiming memory that would be reclaimed over time anyway. Additionally, the model & page instances remain alive. However, practically, we have less perceived leakage. Intuitively, reclaiming memory after a lot of gen 2 memory is unused makes sense.


You might notice we still have Gen0 and Gen1 lingering, to clean these up and remove lingering DependencyObjects, I've adapted the navigator code as follows:

  • Traverse the visual tree
    • Remove all event Handlers.
    • Clear DataContext.
    • Dispose disposables.
  • GC with maximum memory pressure after a grace period.
  • GC again after another grace period with maximum memory pressure.
  • GC periodically to trim gen0 and gen1, otherwise lingering until memory presure trims them.
private static int s_gcCleanupCount;

private static readonly Lazy<Thread> s_gcCleanup = new(() =>
{
    var t = new Thread(static () =>
    {
        while (!WindowManager.IsShutdownRequested)
        {
            Thread.Sleep(8000);
            var x = Interlocked.Decrement(ref s_gcCleanupCount);
            if (x >= 0)
            {
                GCollectAggressive();
            }
            else if (Interlocked.CompareExchange(ref s_gcCleanupCount, 0, x) == x)
            {
                GC.Collect(1, GCCollectionMode.Forced, true);
            }
        }
    });
    t.Start();
    return t;
});

public static async ValueTask To<TPage>(
    this INavigator? nav,
    object sender,
    object? data = null,
    CancellationToken ct = default
) where TPage : Page
{
    if (nav is null)
    {
        typeof(NavigationExtensions).Log().LogWarning("Navigation by {Sender} without navigator", sender);
        return;
    }

    await ClearOnUnload(nav, sender, ct).ConfigureAwait(false);

    await nav.NavigateViewAsync<TPage>(sender, data: data, cancellation: ct)
        .ConfigureAwait(false);

    NavigationGcCleanup();
}

public static async Task ClearOnUnload(INavigator? nav, object sender, CancellationToken ct = default)
{
    await WindowManager.Dispatch(static t =>
            {
                if (t.sender is Window { Content: FrameworkElement windowRoot })
                {
                    windowRoot.Unloaded += ClearContext;
                    return;
                }

                var page = (t.sender as UIElement)?.FindAscendantOrSelf<Page>()
                    ?? (t.nav as FrameNavigator)?.Control?.Content as Page
                    ?? WindowManager.Active.Content.FindDescendantOrSelf<Page>();
                if (page is not null)
                {
                    page.Unloaded += ClearContext;
                }

                return;
            },
            (sender, nav),
            ct)
        .ConfigureAwait(false);
}

private static void NavigationGcCleanup()
{
    var resume = false;
    try
    {
        if (!ExecutionContext.IsFlowSuppressed())
        {
            ExecutionContext.SuppressFlow();
            resume = true;
        }

        Volatile.Write(ref s_gcCleanupCount, 2);
        _ = s_gcCleanup.Value;
    }
    finally
    {
        if (resume)
        {
            ExecutionContext.RestoreFlow();
        }
    }
}

private static readonly nint s_addMemoryPressure = nint.MaxValue >> 3;

private static void GCollectAggressive()
{
    GC.AddMemoryPressure(s_addMemoryPressure);
    GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, true, true);
    GC.Collect(0, GCCollectionMode.Forced, false);
    GC.RemoveMemoryPressure(s_addMemoryPressure);
}

private static readonly ImmutableArray<string> s_clearCollectionProperties =
    ["Source", "ItemsSource", "DataSource", "Data"];

private static void ClearContext(object sender, RoutedEventArgs args)
{
    ConditionalWeakTable<object, Task?> disposeTasks = [];

    Queue<object> q = [];
    q.Enqueue(sender);

    while (q.TryDequeue(out var o))
    {
        if (o is UIElement e)
        {
            var len = VisualTreeHelper.GetChildrenCount(e);
            for (var i = 0; i < len; i++)
            {
                var c = VisualTreeHelper.GetChild(e, i);
                TryEnqueue(c);
            }
        }

        if (o is FrameworkElement fe)
        {
            ClearFrameworkElement(fe);
        }

        ClearDisposable(o);

        EventHelper.RemoveEventHandlers(o);
    }

    Task.WaitAll(disposeTasks.WhereNotNull(x => x.Value));
    disposeTasks.Clear();
    return;

    void TryEnqueue(object? value)
    {
        if (value is not Frame
            && value is INotifyPropertyChanged or ICollectionView or DependencyObject or IDisposable or IList)
        {
            q.Enqueue(value);
        }
    }

    void ClearFrameworkElement(FrameworkElement fe)
    {
        ClearDisposable(fe.DataContext);

        fe.DataContext = null;
        fe.Triggers.Clear();
        if (Interaction.GetBehaviors(fe) is { } bc)
        {
            bc.Detach();
        }

        foreach (var dp in s_clearCollectionProperties.WhereNotNull(fe.FindDependencyProperty))
        {
            fe.ClearValue(dp);
        }
    }

    void ClearDisposable(object? o)
    {
        if (o is null)
        {
            return;
        }

        if (disposeTasks.GetValue(o, CreateClearDisposableTask) is null)
        {
            disposeTasks.Remove(o);
        }
    }

    static Task? CreateClearDisposableTask(object? o)
    {
        try
        {
            return o switch
            {
                IAsyncDisposable ad => ad.DisposeAsync() is { IsCompletedSuccessfully: false } t
                    ? Task.Run(async () => await t.ConfigureAwait(false))
                    : null,
                IDisposable d => DisposeNoTask(d),
                _ => null,
            };
        }
        catch (Exception ex)
        {
            typeof(NavigationExtensions).Log()
                .LogWarning(ex,
                    "Failed to dispose {Type}",
                    o?.GetType());
            return null;
        }

        static Task? DisposeNoTask(IDisposable d)
        {
            d.Dispose();
            return null;
        }
    }
}

public static async ValueTask Back(
    this INavigator? nav,
    object sender,
    object resultData,
    CancellationToken ct = default
)
{
    if (nav is null)
    {
        typeof(NavigationExtensions).Log().LogWarning("Navigation by {Sender} without navigator", sender);
        return;
    }

    await ClearOnUnload(nav, sender, ct).ConfigureAwait(false);

    if (!await nav.CanGoBack().ConfigureAwait(false))
    {
        WindowManager.Active.Close();
    }

    await nav.NavigateBackWithResultAsync(sender, data: resultData, cancellation: ct).ConfigureAwait(false);

    NavigationGcCleanup();
}

public static async ValueTask Back(
    this INavigator? nav,
    object sender,
    CancellationToken ct = default
)
{
    if (nav is null)
    {
        typeof(NavigationExtensions).Log().LogWarning("Navigation by {Sender} without navigator", sender);
        return;
    }

    await ClearOnUnload(nav, sender, ct).ConfigureAwait(false);

    if (!await nav.CanGoBack().ConfigureAwait(false))
    {
        WindowManager.Active.Close();
    }

    await nav.NavigateBackAsync(sender, cancellation: ct).ConfigureAwait(false);

    NavigationGcCleanup();
}

Profiling now shows a noticeable improvement in memory reclamation after navigation (10MB vs 17MB) -> 40% less leakage.

Image

Two yellow spikes in GC freeze the world phase immediately after navigation, no performance impact from then on.

Image

Pages remain actively referenced. I doubt I can get anymore from user code than guaranteeing that the most common memory-leak causes are resolved.

ProphetLamb avatar Aug 12 '25 08:08 ProphetLamb

Hey @ProphetLamb thanks for the investigation, but I tried to repro it in 3 different ways:

  1. Gallery navigating between pages
  2. FeedView with refresh
  3. Using uno's navigation extensions, but in all cases the memory was collected at some point by the GC

For the "Playground", this project has so much specificities that we cannot really use it for memory profiling.

~~Un~~fortunately for none of them I was able to find a real memory leak

[!NOTE] I was able to have global memory consumption increase in Window's TaskManager, but requesting GC from the profiler ended by releasing the memory. This only underlines that the runtime returns the memory to the OS only when either OS requests it, either when the dotnet runtime determines it will not need it anymore. This behavior ensure better performance of the runtime.

Are you able to provide a repro that we can use to troubleshot the issue?

To easily underline any memory leak, my technic is to create heavy `byte[]` with ID written in it, so we can track which one is leaking directly from the profiler ```csharp private byte[] _myHugeByteArray = CreateArray(Id);
private static byte[] CreateArray(int id)
{
	var data = Enumerable.Range(0, 512 * 1024 * 1024).Select(_ => (byte)Random.Shared.Next(0, 8)).ToArray();

	data[0] = (byte)(id >> 3 * 8);
	data[1] = (byte)(id >> 2 * 8);
	data[2] = (byte)(id >> 1 * 8);
	data[3] = (byte)(id >> 0 * 8);
	data[4] = 0;
	data[5] = 0;
	data[6] = 0;
	data[7] = 0;

	return data;
}
</details>

dr1rrb avatar Aug 13 '25 15:08 dr1rrb

@dr1rrb Your findings with the Uno Gallery greatly differ from mine. I've just retested it with the current main branch on profiler. GC collected a few seconds before the screenshot:

win SDK

Image

desktop

Image

Why could that be?

ProphetLamb avatar Aug 13 '25 16:08 ProphetLamb

So far I've found three significant memory leaks:

  1. A memory leak is caused by the model being created using DI.
  2. A further memory leak is caused by transient disposables injected into the model not being disposed.
  3. A further memory leak is caused in WinUI and Uno native and managed UIElements leaking memory post navigation.
  • Memory leak 1 & 2 may fixed by creating a new subordinate IServiceScope when constructing the page, and disposing the scope afterwards.
  • Memory leak 3 requires more investigation than I'm able to invest.

Regarding 1. on WinUI

Image

The only strong retention path leads to the IRegion.Services which holds a List of all IDisposable, and IAsyncDisposable objects created in the scope.

Multiple memory issues using service scopes are apparent when reviewing source code. For instance the IServiceScope is discarded, the service provider ServiceProviderEngineScope: IAsyncDisposable is never disposed:

internal static IServiceProvider CreateNavigationScope(this IServiceProvider services)
{
	var scoped = services.CreateScope().ServiceProvider;
	return scoped.CloneScopedInstance<Window>(services)
				.CloneScopedInstance<IDispatcher>(services);
}

In any case, ServiceProviderEngineScope holds a strong reference to each and every Model created, because the Uno source generator implements IAsyncDisposable for models:

[global::Uno.Extensions.Reactive.Bindings.Model(typeof(global::FancyTool.Presentation.ProjectOverviewViewModel))]
[global::System.Runtime.CompilerServices.CreateNewOnMetadataUpdate]
partial record MyModel: global::System.IAsyncDisposable, global::Uno.Extensions.Reactive.Core.ISourceContextAware, global::Uno.Extensions.Reactive.Bindings.IModel<global::FancyTool.Presentation.ProjectOverviewViewModel>
{
    
}

Since the IRegion is ambient models are never disposed throughout the lifetime of the Window.

I've created a reflection based method to remove the instance from ServiceProviderEngineScope._disposables after navigation, and dispose it manually. This solved the memory leakage.

Regarding 2. & 3. on UNO/Skia

Notice that the allocated bytes between snapshot 1 and 2 increased significantly. To achieve this simply navigate backwards and forwards again. Do note that the NavigationCacheMode=Disabled. Image

The Grids are only kept alive by the ProjectOverviewPage.

Image

Each ProjectOverviewPage is kept alive due to its ProjectOverviewModel. Which is again only kept alive by the scoped IServiceProvider:

Image

In essence both memory leaks have the same cause.

ProphetLamb avatar Oct 21 '25 12:10 ProphetLamb

@dr1rrb is there a hook in UNO to act before and after every navigation request? Currently I'm using a page base type. This doesn't work for contentcontrolnavigators and is rather ugly.

ProphetLamb avatar Oct 24 '25 08:10 ProphetLamb

@ebariche I've added more findings on what is leaking and why the repro did not catch it.

ProphetLamb avatar Oct 28 '25 16:10 ProphetLamb

So far I've found three significant memory leaks:

  1. A memory leak is caused by the model being created using DI.
  2. A further memory leak is caused by transient disposables injected into the model not being disposed.
  3. A further memory leak is caused in WinUI and Uno native and managed UIElements leaking memory post navigation.
  • Memory leak 1 & 2 may fixed by creating a new subordinate IServiceScope when constructing the page, and disposing the scope afterwards.
  • Memory leak 3 requires more investigation than I'm able to invest.

I think these leaks really tie into how Navigation is done in UNO. The scope of the viewmodels is not tied to NavigationCache if I'm correct. So you need to explicitly dispose of it on navigation if you need to (which is important to get cancellation tokens cancelled when using MVUX). Like you mentioned scope is created but never disposed, so this makes it almost impossible to use DI correctly when using anything else but singletons.

I would expect services to be scoped when doing forward navigation (and NavigationCache is not disabled) and the scope to be disposed when navigating back, clearing the back stack or navigating to root.

tdy-niek-klaverstijn avatar Dec 01 '25 10:12 tdy-niek-klaverstijn