[leak-scan] GradientBrush.GradientStops — shared collection retains each brush
- Dominant language
- C#
- Stars
- 23.3k
- Forks
- 2k
- Avg merge
- 1d 15h
- Merged PRs (30d)
- 290
Description
> [!IMPORTANT]
> This issue and its standalone runtime evidence were generated by the **Daily Memory Leak Hunter** AI workflow.
## Description
A long-lived shared GradientStopCollection roots every transient GradientBrush assigned to it through a strong CollectionChanged subscription.
## Retention path
`shared GradientStopCollection -> CollectionChanged multicast delegate -> GradientBrush.OnGradientStopCollectionChanged -> brush -> BindingContext payload`
src/Controls/src/Core/GradientBrush.cs:64-86 attaches directly and detaches only when GradientStops is explicitly replaced.
## Standalone repro
`leakprobe.csproj`:
```xml
net10.0
enable
false
```
`LeakTest.cs` (covered by `GradientBrush_GradientStops_Leaks`):
```csharp
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Runtime.CompilerServices;
using Microsoft.Maui.Controls;
using Microsoft.Maui.Controls.Shapes;
using Microsoft.Maui.Controls.Xaml.Diagnostics;
using Xunit;
public class LeakTest
{
const int N = 30;
[Fact]
public void VisualElement_Resources_Leaks() =>
AssertLeak(CreateResourcesControl, CreateResourcesLeaky, CreateResourcesMitigation);
[Fact]
public void ResourceDictionary_MergedDictionaries_Leaks() =>
AssertLeak(CreateMergedControl, CreateMergedLeaky, CreateMergedMitigation);
[Fact]
public void Picker_ItemsSource_Leaks() =>
AssertLeak(CreatePickerControl, CreatePickerLeaky, CreatePickerMitigation);
[Fact]
public void Label_FormattedText_Leaks() =>
AssertLeak(CreateFormattedControl, CreateFormattedLeaky, CreateFormattedMitigation);
[Fact]
public void GradientBrush_GradientStops_Leaks() =>
AssertLeak(CreateGradientControl, CreateGradientLeaky, CreateGradientMitigation);
[Fact]
public void PathFigure_Segments_Leaks() =>
AssertLeak(CreateSegmentsControl, CreateSegmentsLeaky, CreateSegmentsMitigation);
[Fact]
public void PathGeometry_Figures_Leaks() =>
AssertLeak(CreateFiguresControl, CreateFiguresLeaky, CreateFiguresMitigation);
[Fact]
public void TransformGroup_Children_Leaks() =>
AssertLeak(CreateTransformsControl, CreateTransformsLeaky, CreateTransformsMitigation);
static void AssertLeak(
Func, object> controlFactory,
Func, object> leakyFactory,
Func, object> mitigationFactory)
{
var control = new List();
var leaky = new List();
var mitigation = new List();
GC.KeepAlive(controlFactory(control));
var leakyRoot = leakyFactory(leaky);
GC.KeepAlive(mitigationFactory(mitigation));
ForceGc();
Assert.Equal(0, Alive(control));
Assert.Equal(N, Alive(leaky));
Assert.Equal(0, Alive(mitigation));
GC.KeepAlive(leakyRoot);
}
static int Alive(IEnumerable references) => references.Count(x => x.IsAlive);
static void ForceGc()
{
for (var i = 0; i < 7; i++)
{
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
}
}
static Payload Track(List references)
{
var payload = new Payload();
references.Add(new WeakReference(payload));
return payload;
}
[MethodImpl(MethodImplOptions.NoInlining)]
static object CreateResourcesControl(List references)
{
for (var i = 0; i < N; i++)
new Label { BindingContext = Track(references) };
return new object();
}
[MethodImpl(MethodImplOptions.NoInlining)]
static object CreateResourcesLeaky(List references)
{
var shared = new ResourceDictionary();
for (var i = 0; i < N; i++)
new Label { BindingContext = Track(references), Resources = shared };
return shared;
}
[MethodImpl(MethodImplOptions.NoInlining)]
static object CreateResourcesMitigation(List references)
{
var shared = new ResourceDictionary();
for (var i = 0; i < N; i++)
{
var label = new Label { BindingContext = Track(references), Resources = shared };
label.Resources = new ResourceDictionary();
}
return shared;
}
[MethodImpl(MethodImplOptions.NoInlining)]
static object CreateMergedControl(List references)
{
for (var i = 0; i < N; i++)
new ResourceDictionary { ["payload"] = Track(references) };
return new object();
}
[MethodImpl(MethodImplOptions.NoInlining)]
static object CreateMergedLeaky(List references)
{
var shared = new ResourceDictionary();
for (var i = 0; i < N; i++)
{
var local = new ResourceDictionary { ["payload"] = Track(references) };
local.MergedDictionaries.Add(shared);
}
return shared;
}
[MethodImpl(MethodImplOptions.NoInlining)]
static object CreateMergedMitigation(List references)
{
var shared = new ResourceDictionary();
for (var i = 0; i < N; i++)
{
var local = new ResourceDictionary { ["payload"] = Track(references) };
local.MergedDictionaries.Add(shared);
local.MergedDictionaries.Clear();
}
return shared;
}
[MethodImpl(MethodImplOptions.NoInlining)]
static object CreatePickerControl(List references)
{
for (var i = 0; i < N; i++)
new Picker { BindingContext = Track(references) };
return new object();
}
[MethodImpl(MethodImplOptions.NoInlining)]
static object CreatePickerLeaky(List references)
{
var shared = new ObservableCollection();
for (var i = 0; i < N; i++)
new Picker { BindingContext = Track(references), ItemsSource = shared };
return shared;
}
[MethodImpl(MethodImplOptions.NoInlining)]
static object CreatePickerMitigation(List references)
{
var shared = new ObservableCollection();
for (var i = 0; i < N; i++)
{
var picker = new Picker { BindingContext = Track(references), ItemsSource = shared };
picker.ItemsSource = null;
}
return shared;
}
[MethodImpl(MethodImplOptions.NoInlining)]
static object CreateSelectionControl(List references)
{
for (var i = 0; i < N; i++)
new CollectionView { BindingContext = Track(references), SelectionMode = SelectionMode.Multiple };
return new object();
}
[MethodImpl(MethodImplOptions.NoInlining)]
static object CreateSelectionLeaky(List references)
{
var shared = new ObservableCollection();
for (var i = 0; i < N; i++)
new CollectionView { BindingContext = Track(references), SelectionMode = SelectionMode.Multiple, SelectedItems = shared };
return shared;
}
[MethodImpl(MethodImplOptions.NoInlining)]
static object CreateSelectionMitigation(List references)
{
var shared = new ObservableCollection();
for (var i = 0; i < N; i++)
{
var view = new CollectionView { BindingContext = Track(references), SelectionMode = SelectionMode.Multiple, SelectedItems = shared };
view.SelectedItems = new List();
}
return shared;
}
[MethodImpl(MethodImplOptions.NoInlining)]
static object CreateFormattedControl(List references)
{
for (var i = 0; i < N; i++)
new Label { BindingContext = Track(references) };
return new object();
}
[MethodImpl(MethodImplOptions.NoInlining)]
static object CreateFormattedLeaky(List references)
{
var shared = new FormattedString();
for (var i = 0; i < N; i++)
new Label { BindingContext = Track(references), FormattedText = shared };
return shared;
}
[MethodImpl(MethodImplOptions.NoInlining)]
static object CreateFormattedMitigation(List references)
{
var shared = new FormattedString();
for (var i = 0; i < N; i++)
{
var label = new Label { BindingContext = Track(references), FormattedText = shared };
label.FormattedText = null;
}
return shared;
}
[MethodImpl(MethodImplOptions.NoInlining)]
static object CreateGradientControl(List references)
{
for (var i = 0; i < N; i++)
new LinearGradientBrush { BindingContext = Track(references) };
return new object();
}
[MethodImpl(MethodImplOptions.NoInlining)]
static object CreateGradientLeaky(List references)
{
var shared = new GradientStopCollection();
for (var i = 0; i < N; i++)
new LinearGradientBrush { BindingContext = Track(references), GradientStops = shared };
return shared;
}
[MethodImpl(MethodImplOptions.NoInlining)]
static object CreateGradientMitigation(List references)
{
var shared = new GradientStopCollection();
for (var i = 0; i < N; i++)
{
var brush = new LinearGradientBrush { BindingContext = Track(references), GradientStops = shared };
brush.GradientStops = new GradientStopCollection();
}
return shared;
}
[MethodImpl(MethodImplOptions.NoInlining)]
static object CreateSegmentsControl(List references)
{
for (var i = 0; i < N; i++)
new PathFigure { BindingContext = Track(references) };
return new object();
}
[MethodImpl(MethodImplOptions.NoInlining)]
static object CreateSegmentsLeaky(List references)
{
var shared = new PathSegmentCollection();
for (var i = 0; i < N; i++)
new PathFigure { BindingContext = Track(references), Segments = shared };
return shared;
}
[MethodImpl(MethodImplOptions.NoInlining)]
static object CreateSegmentsMitigation(List references)
{
var shared = new PathSegmentCollection();
for (var i = 0; i < N; i++)
{
var figure = new PathFigure { BindingContext = Track(references), Segments = shared };
figure.Segments = new PathSegmentCollection();
}
return shared;
}
[MethodImpl(MethodImplOptions.NoInlining)]
static object CreateFiguresControl(List references)
{
for (var i = 0; i < N; i++)
new PathGeometry { BindingContext = Track(references) };
return new object();
}
[MethodImpl(MethodImplOptions.NoInlining)]
static object CreateFiguresLeaky(List references)
{
var shared = new PathFigureCollection();
for (var i = 0; i < N; i++)
new PathGeometry { BindingContext = Track(references), Figures = shared };
return shared;
}
[MethodImpl(MethodImplOptions.NoInlining)]
static object CreateFiguresMitigation(List references)
{
var shared = new PathFigureCollection();
for (var i = 0; i < N; i++)
{
var geometry = new PathGeometry { BindingContext = Track(references), Figures = shared };
geometry.Figures = new PathFigureCollection();
}
return shared;
}
[MethodImpl(MethodImplOptions.NoInlining)]
static object CreateTransformsControl(List references)
{
for (var i = 0; i < N; i++)
new TransformGroup { BindingContext = Track(references) };
return new object();
}
[MethodImpl(MethodImplOptions.NoInlining)]
static object CreateTransformsLeaky(List references)
{
var shared = new TransformCollection();
for (var i = 0; i < N; i++)
new TransformGroup { BindingContext = Track(references), Children = shared };
return shared;
}
[MethodImpl(MethodImplOptions.NoInlining)]
static object CreateTransformsMitigation(List references)
{
var shared = new TransformCollection();
for (var i = 0; i < N; i++)
{
var group = new TransformGroup { BindingContext = Track(references), Children = shared };
group.Children = new TransformCollection();
}
return shared;
}
[MethodImpl(MethodImplOptions.NoInlining)]
static object CreateDiagnosticsControl(List references)
{
for (var i = 0; i < N; i++)
new DiagnosticSubscriber(Track(references));
return new object();
}
[MethodImpl(MethodImplOptions.NoInlining)]
static object CreateDiagnosticsLeaky(List references)
{
for (var i = 0; i < N; i++)
{
var subscriber = new DiagnosticSubscriber(Track(references));
BindingDiagnostics.BindingFailed += subscriber.OnBindingFailed;
}
return new object();
}
[MethodImpl(MethodImplOptions.NoInlining)]
static object CreateDiagnosticsMitigation(List references)
{
for (var i = 0; i < N; i++)
{
var subscriber = new DiagnosticSubscriber(Track(references));
BindingDiagnostics.BindingFailed += subscriber.OnBindingFailed;
BindingDiagnostics.BindingFailed -= subscriber.OnBindingFailed;
}
return new object();
}
sealed class Payload
{
public byte[] Data { get; } = new byte[1024 * 1024];
}
sealed class DiagnosticSubscriber
{
readonly Payload _payload;
public DiagnosticSubscriber(Payload payload) => _payload = payload;
public void OnBindingFailed(object? sender, BindingBaseErrorEventArgs args) =>
GC.KeepAlive(_payload);
}
}
```
Run `dotnet test --logger "console;verbosity=normal"`. This restores shipped Microsoft.Maui.Controls 10.0.0 and runs on plain net10.0.
## Observed results
| Scenario | Alive | Retained payload |
|---|---:|---:|
| Control | 0 / 30 | 0 MB |
| Mitigation | 0 / 30 | 0 MB |
| Leaky | 30 / 30 | 30 MB |
## Impact and scope
- **Affected platforms:** All; entirely managed and cross-platform.
- **Condition:** A gradient-stop collection is shared and outlives the brush. Replacing GradientStops releases the brush.
- **Suggested fix:** Use a weak collection-changed proxy, or add managed teardown that detaches from the externally owned collection.
- **Scope note:** Framework-hardening bug: public collection assignment permits reuse but creates an unexpected reverse lifetime dependency.
> Generated by [Daily Memory Leak Hunter](https://github.com/dotnet/maui/actions/runs/34730113586) · gpt56 · 318.3 AIC · ⌖ 34.3 AIC · ⊞ 31.7K · [◷](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/GradientBrush.cs:64-86 and the GradientBrush_GradientStops_Leaks case in LeakTest.cs. Trace the GradientStops subscription and replacement behavior, then run the standalone leak test; done means the leaky case no longer retains the transient brushes while the control and mitigation cases remain collectible.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- csharp
- Domain
- mobile-dev
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 68/100