[leak-scan] VisualElement.Resources — shared dictionary retains the element
- Dominant language
- C#
- Stars
- 23.3k
- Forks
- 2k
- Avg merge
- 1d 14h
- Merged PRs (30d)
- 296
Description
> [!IMPORTANT]
> **AI-generated by the Daily Memory Leak Hunter — dotnet/maui workflow.** This finding was empirically confirmed against the shipped `Microsoft.Maui.Controls` 10.0.0 package on plain `net10.0`.
## Description
Assigning a long-lived ResourceDictionary to a transient VisualElement strongly retains the element. The Resources setter attaches the element directly to the dictionary ValuesChanged event, and that handler is removed only when Resources is replaced.
## Retention path
`shared ResourceDictionary -> ValuesChanged delegate -> VisualElement.OnResourcesChanged -> transient Label -> 1 MB payload`
- `src/Controls/src/Core/VisualElement/VisualElement.cs:1170-1191` subscribes to the new dictionary and removes the handler only when the property changes.\n- `src/Controls/src/Core/ResourceDictionary.cs:375-378,419-432` stores and invokes the normal strong event.
## Standalone repro
The project below exercises all candidates found in this sweep. Run the named `VisualElement_Resources_Leaks` fact to isolate this rooting API.
`leakprobe.csproj`:
```xml
net10.0
enable
false
```
`LeakTest.cs`:
```csharp
using System;
using System.Collections;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Windows.Input;
using Microsoft.Maui.Controls;
using Xunit;
[assembly: CollectionBehavior(DisableTestParallelization = true)]
public sealed class LeakTest
{
const int N = 30;
static readonly BindableProperty PayloadProperty =
BindableProperty.CreateAttached("Payload", typeof(byte[]), typeof(LeakTest), default(byte[]));
[Fact]
public void VisualElement_Resources_Leaks()
{
var shared = new ResourceDictionary();
var control = CreateResources(shared, Scenario.Control);
var leaky = CreateResources(shared, Scenario.Leaky);
var mitigation = CreateResources(shared, Scenario.Mitigation);
AssertCounts(nameof(VisualElement_Resources_Leaks), control, leaky, mitigation);
GC.KeepAlive(shared);
}
[Fact]
public void ResourceDictionary_MergedDictionaries_Leaks()
{
var shared = new ResourceDictionary();
var control = CreateMergedDictionaries(shared, Scenario.Control);
var leaky = CreateMergedDictionaries(shared, Scenario.Leaky);
var mitigation = CreateMergedDictionaries(shared, Scenario.Mitigation);
AssertCounts(nameof(ResourceDictionary_MergedDictionaries_Leaks), control, leaky, mitigation);
GC.KeepAlive(shared);
}
[Fact]
public void IndicatorView_ItemsSource_Leaks()
{
var shared = new ObservableCollection();
var control = CreateIndicatorViews(shared, Scenario.Control);
var leaky = CreateIndicatorViews(shared, Scenario.Leaky);
var mitigation = CreateIndicatorViews(shared, Scenario.Mitigation);
AssertCounts(nameof(IndicatorView_ItemsSource_Leaks), control, leaky, mitigation);
GC.KeepAlive(shared);
}
[Fact]
public void Picker_ItemsSource_Leaks()
{
var shared = new ObservableCollection();
var control = CreatePickers(shared, Scenario.Control);
var leaky = CreatePickers(shared, Scenario.Leaky);
var mitigation = CreatePickers(shared, Scenario.Mitigation);
AssertCounts(nameof(Picker_ItemsSource_Leaks), control, leaky, mitigation);
GC.KeepAlive(shared);
}
[Fact]
public void FormattedString_Spans_Leaks()
{
var shared = new Span();
var control = CreateFormattedStrings(shared, Scenario.Control);
var leaky = CreateFormattedStrings(shared, Scenario.Leaky);
var mitigation = CreateFormattedStrings(shared, Scenario.Mitigation);
AssertCounts(nameof(FormattedString_Spans_Leaks), control, leaky, mitigation);
GC.KeepAlive(shared);
}
[Fact]
public void BackButtonBehavior_Command_Leaks()
{
var shared = new TestCommand();
var control = CreateBackButtonBehaviors(shared, Scenario.Control);
var leaky = CreateBackButtonBehaviors(shared, Scenario.Leaky);
var mitigation = CreateBackButtonBehaviors(shared, Scenario.Mitigation);
AssertCounts(nameof(BackButtonBehavior_Command_Leaks), control, leaky, mitigation);
GC.KeepAlive(shared);
}
[Fact]
public void SwipeItemView_Command_Leaks()
{
var shared = new TestCommand();
var control = CreateSwipeItemViews(shared, Scenario.Control);
var leaky = CreateSwipeItemViews(shared, Scenario.Leaky);
var mitigation = CreateSwipeItemViews(shared, Scenario.Mitigation);
AssertCounts(nameof(SwipeItemView_Command_Leaks), control, leaky, mitigation);
GC.KeepAlive(shared);
}
[Fact]
public void GradientBrush_GradientStops_Clear_Leaks()
{
var control = CreateGradientBrushes(Scenario.Control);
var leaky = CreateGradientBrushes(Scenario.Leaky);
var mitigation = CreateGradientBrushes(Scenario.Mitigation);
AssertCounts(nameof(GradientBrush_GradientStops_Clear_Leaks), control.References, leaky.References, mitigation.References);
GC.KeepAlive(control.Roots);
GC.KeepAlive(leaky.Roots);
GC.KeepAlive(mitigation.Roots);
}
[MethodImpl(MethodImplOptions.NoInlining)]
static List CreateResources(ResourceDictionary shared, Scenario scenario)
{
var references = new List(N);
for (var i = 0; i < N; i++)
{
var subject = WithPayload(new Label());
subject.Resources = scenario == Scenario.Control ? new ResourceDictionary() : shared;
if (scenario == Scenario.Mitigation)
subject.Resources = new ResourceDictionary();
references.Add(new WeakReference(subject));
}
return references;
}
[MethodImpl(MethodImplOptions.NoInlining)]
static List CreateMergedDictionaries(ResourceDictionary shared, Scenario scenario)
{
var references = new List(N);
for (var i = 0; i < N; i++)
{
var subject = WithPayload(new Label());
var leaf = scenario == Scenario.Control ? new ResourceDictionary() : shared;
subject.Resources.MergedDictionaries.Add(leaf);
if (scenario == Scenario.Mitigation)
subject.Resources.MergedDictionaries.Remove(leaf);
references.Add(new WeakReference(subject));
}
return references;
}
[MethodImpl(MethodImplOptions.NoInlining)]
static List CreateIndicatorViews(ObservableCollection shared, Scenario scenario)
{
var references = new List(N);
for (var i = 0; i < N; i++)
{
var subject = WithPayload(new IndicatorView());
if (scenario != Scenario.Control)
subject.ItemsSource = shared;
if (scenario == Scenario.Mitigation)
subject.ItemsSource = null;
references.Add(new WeakReference(subject));
}
return references;
}
[MethodImpl(MethodImplOptions.NoInlining)]
static List CreatePickers(ObservableCollection shared, Scenario scenario)
{
var references = new List(N);
for (var i = 0; i < N; i++)
{
var subject = WithPayload(new Picker());
if (scenario != Scenario.Control)
subject.ItemsSource = shared;
if (scenario == Scenario.Mitigation)
subject.ItemsSource = null;
references.Add(new WeakReference(subject));
}
return references;
}
[MethodImpl(MethodImplOptions.NoInlining)]
static List CreateFormattedStrings(Span shared, Scenario scenario)
{
var references = new List(N);
for (var i = 0; i < N; i++)
{
var subject = WithPayload(new FormattedString());
if (scenario != Scenario.Control)
subject.Spans.Add(shared);
if (scenario == Scenario.Mitigation)
subject.Spans.Remove(shared);
references.Add(new WeakReference(subject));
}
return references;
}
[MethodImpl(MethodImplOptions.NoInlining)]
static List CreateBackButtonBehaviors(TestCommand shared, Scenario scenario)
{
var references = new List(N);
for (var i = 0; i < N; i++)
{
var subject = WithPayload(new BackButtonBehavior());
if (scenario != Scenario.Control)
subject.Command = shared;
if (scenario == Scenario.Mitigation)
subject.Command = null;
references.Add(new WeakReference(subject));
}
return references;
}
[MethodImpl(MethodImplOptions.NoInlining)]
static List CreateSwipeItemViews(TestCommand shared, Scenario scenario)
{
var references = new List(N);
for (var i = 0; i < N; i++)
{
var subject = WithPayload(new SwipeItemView());
if (scenario != Scenario.Control)
subject.Command = shared;
if (scenario == Scenario.Mitigation)
subject.Command = null;
references.Add(new WeakReference(subject));
}
return references;
}
[MethodImpl(MethodImplOptions.NoInlining)]
static GradientResult CreateGradientBrushes(Scenario scenario)
{
var references = new List(N);
var roots = new List(N);
for (var i = 0; i < N; i++)
{
var subject = WithPayload(new LinearGradientBrush());
var stop = new GradientStop();
roots.Add(stop);
if (scenario != Scenario.Control)
subject.GradientStops.Add(stop);
if (scenario == Scenario.Leaky)
subject.GradientStops.Clear();
if (scenario == Scenario.Mitigation)
subject.GradientStops.Remove(stop);
references.Add(new WeakReference(subject));
}
return new GradientResult(references, roots);
}
static T WithPayload(T subject) where T : BindableObject
{
subject.SetValue(PayloadProperty, new byte[1024 * 1024]);
return subject;
}
static void AssertCounts(string name, IEnumerable control, IEnumerable leaky, IEnumerable mitigation)
{
ForceGc();
var controlAlive = CountAlive(control);
var leakyAlive = CountAlive(leaky);
var mitigationAlive = CountAlive(mitigation);
Console.WriteLine($"RESULT {name}: Control={controlAlive}/{N}, Leaky={leakyAlive}/{N}, Mitigation={mitigationAlive}/{N}, RetainedMB={leakyAlive}");
Assert.Equal(0, controlAlive);
Assert.Equal(N, leakyAlive);
Assert.Equal(0, mitigationAlive);
}
static int CountAlive(IEnumerable references) => references.Count(reference => reference.IsAlive);
static void ForceGc()
{
for (var i = 0; i < 7; i++)
{
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
}
}
enum Scenario
{
Control,
Leaky,
Mitigation
}
sealed class TestCommand : ICommand
{
public event EventHandler? CanExecuteChanged;
public bool CanExecute(object? parameter) => true;
public void Execute(object? parameter) { }
}
sealed record GradientResult(List References, List Roots);
}
```
Run:
```bash
cd leakprobe
dotnet test --filter FullyQualifiedName=LeakTest.VisualElement_Resources_Leaks --logger "console;verbosity=normal"
```
## Observed results
| Scenario | Alive after full GC | Retained payload |
|---|---:|---:|
| Control | 0 / 30 | 0 MB |
| Mitigation | 0 / 30 | 0 MB |
| Leaky | **30 / 30** | **30 MB** |
The xUnit fact passed: every leaky subject remained alive while every control and mitigated subject was collected.
## Impact and condition
This retention is entirely in cross-platform managed code and therefore affects all platforms. It occurs when a shared or otherwise long-lived dictionary remains assigned after the element is discarded; replacing Resources is the tested mitigation.
## Suggested fix
Use weak event plumbing for ValuesChanged consumers, or add lifecycle cleanup that detaches the resource dictionary subscription when the element leaves use.
## Scope note
This is a framework-hardening issue around a common resource-sharing pattern. The current caller workaround is hidden manual teardown of Resources.
> Generated by [Daily Memory Leak Hunter](https://github.com/dotnet/maui/actions/runs/33824647084) · gpt56 · 612.5 AIC · ⌖ 27.5 AIC · ⊞ 32.5K · [◷](https://github.com/search?q=repo%3Adotnet%2Fmaui+is%3Aissue+%22gh-aw-workflow-call-id%3A+dotnet%2Fmaui%2Fdaily-leak-hunter%22&type=issues)
Contributor guide
Research direction
Start with src/Controls/src/Core/VisualElement/VisualElement.cs:1170-1191 and the ValuesChanged implementation in src/Controls/src/Core/ResourceDictionary.cs:375-378,419-432. Run the supplied leakprobe.csproj test with the VisualElement_Resources_Leaks filter and confirm the retention behavior. Done means the leaky scenario no longer retains discarded elements while the control and mitigation scenarios still collect them.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- csharp
- Domain
- desktop-dev, frontend, mobile-dev
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 48/100