dotnet / dotnet/maui

[leak-scan] GradientBrush.GradientStops — shared collection retains each brush

Open
#38,501 0 comments 0 reactions 0 assignees View on GitHub
agentic-workflows perf/memory-leak 💦
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.