[leak-scan] Picker.ItemsSource — unattached picker remains subscribed
- Dominant language
- C#
- Stars
- 23.3k
- Forks
- 2k
- Avg merge
- 1d 10h
- Merged PRs (30d)
- 297
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 observable collection to a Picker that never receives a handler strongly retains the Picker. OnItemsSourceChanged always subscribes, while handler-detachment cleanup cannot run for a control that was never attached.
## Retention path
`shared ObservableCollection -> CollectionChanged delegate -> Picker.CollectionChanged -> transient Picker -> 1 MB payload`
- `src/Controls/src/Core/Picker/Picker.cs:334-343` always subscribes after ItemsSource changes.\n- `src/Controls/src/Core/Picker/Picker.cs:372-383` only adds handler-lifecycle cleanup after handler transitions.\n- `src/Controls/src/Core/Picker/Picker.cs:411-431` stores the direct strong collection subscription.
## Standalone repro
The project below exercises all candidates found in this sweep. Run the named `Picker_ItemsSource_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.Picker_ItemsSource_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 an observable ItemsSource outlives a Picker that is discarded without a handler transition; assigning ItemsSource to null is the tested mitigation.
## Suggested fix
Use weak collection notification plumbing, or add an ownership teardown path that does not depend on a handler having existed.
## Scope note
This is a clear lifecycle gap for unattached or preconstructed controls, although callers can work around it by clearing ItemsSource.
> 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
Run the named Picker_ItemsSource_Leaks fact in the provided leakprobe project to reproduce the retention. Then inspect src/Controls/src/Core/Picker/Picker.cs at lines 334-343, 372-383, and 411-431, focusing on ItemsSource subscription and handler cleanup. Done means the unattached Picker no longer remains alive after collection while the control and mitigation cases still pass.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- csharp
- Domain
- frontend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 58/100