Memory leak
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.
- Navigate back and forth between two pages: 'Overview' and 'Grid Lines'. (Or 'Cards' and 'ViewBox' for UNO playground)
- Check memory usage in task manager
- 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.
Memory profiling shows
- Controls are not disposed
- DependencyObjects are not disposed
In the 4th snapshot I've navigated 3 times, and we have 4 instances of our control and view.
Here is the 5th snapshot with 5 instances of our control:
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 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?
@ProphetLamb Why are you mentioning the
FeedViewin the title? Apparently in your repro there is noFeedView, 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.
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
With forced GC
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
eventHandlers. - Clear
DataContext. - Dispose disposables.
- Remove all
- 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.
Two yellow spikes in GC freeze the world phase immediately after navigation, no performance impact from then on.
Pages remain actively referenced. I doubt I can get anymore from user code than guaranteeing that the most common memory-leak causes are resolved.
Hey @ProphetLamb thanks for the investigation, but I tried to repro it in 3 different ways:
- Gallery navigating between pages
FeedViewwith refresh- 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 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
desktop
Why could that be?
So far I've found three significant memory leaks:
- A memory leak is caused by the model being created using DI.
- A further memory leak is caused by transient disposables injected into the model not being disposed.
- 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
IServiceScopewhen constructing the page, and disposing the scope afterwards. - Memory leak 3 requires more investigation than I'm able to invest.
Regarding 1. on WinUI
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.
The Grids are only kept alive by the ProjectOverviewPage.
Each ProjectOverviewPage is kept alive due to its ProjectOverviewModel. Which is again only kept alive by the scoped IServiceProvider:
In essence both memory leaks have the same cause.
@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.
@ebariche I've added more findings on what is leaking and why the repro did not catch it.
So far I've found three significant memory leaks:
- A memory leak is caused by the model being created using DI.
- A further memory leak is caused by transient disposables injected into the model not being disposed.
- 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
IServiceScopewhen 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.