dotnet / dotnet/maui

[leak-scan] Picker.ItemsSource — unattached picker remains subscribed

Open
#38,176 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 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

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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.