dotnet / dotnet/maui

[leak-scan] Picker.ItemsSource — shared CollectionChanged publisher retains Picker

Open
#38,499 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 shared observable ItemsSource strongly retains every transient Picker through its CollectionChanged handler when the picker is dropped without explicitly clearing ItemsSource.

## Retention path

`shared ObservableCollection -> CollectionChanged multicast delegate -> Picker.CollectionChanged -> Picker -> BindingContext payload`

src/Controls/src/Core/Picker/Picker.cs:411-431 subscribes directly; cleanup depends on changing the source or a handler-detach path.

## Standalone repro

`leakprobe.csproj`:

```xml


net10.0
enable
false






```

`LeakTest.cs` (covered by `Picker_ItemsSource_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:** The shared collection outlives a picker that never received a handler or whose source was not cleared. Setting ItemsSource to null releases it.
- **Suggested fix:** Use WeakNotifyCollectionChangedProxy for the source subscription so collection lifetime does not determine picker lifetime.
- **Scope note:** Clear framework bug: a purely managed picker can be retained before any platform handler exists.

> 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 at src/Controls/src/Core/Picker/Picker.cs:411-431 to inspect the ItemsSource and CollectionChanged subscription path. Run the standalone LeakTest.cs test Picker_ItemsSource_Leaks and compare its leak and mitigation cases; done means transient Pickers are no longer retained by a shared ObservableCollection and the test passes.

Written by the indexing model from the issue text.

Assessment

Tech stack
csharp
Domain
frontend
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.