dotnet / dotnet/maui

[leak-scan] Picker.ItemsSource — pre-handler collection subscription retains the picker

Open
#37,636 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 14h
Merged PRs (30d)
296

Description

> [!IMPORTANT]
> AI-generated by the **Daily Memory Leak Hunter — dotnet/maui** workflow. The finding below was confirmed empirically against the shipped `Microsoft.Maui.Controls` 10.0.0 package.

## Description

Setting an observable `Picker.ItemsSource` subscribes immediately, even before a handler exists. If such a picker is discarded without ever receiving a handler, `OnHandlerChanged` never runs and the shared source retains the picker indefinitely. Clearing `ItemsSource` releases it.

## Retention path

`shared ObservableCollection -> CollectionChanged invocation list -> Picker.CollectionChanged -> Picker -> BindingContext payload`

`src/Controls/src/Core/Picker/Picker.cs:329-342` always subscribes when `ItemsSource` changes. `Picker.cs:372-383` unsubscribes when an existing handler becomes null, but that cleanup cannot run for a picker that never had a handler. The strong subscription itself is added at `Picker.cs:411-420`.

## Repro

`leakprobe.csproj`:

```xml


net10.0
enable
false






```

`LeakTest.cs`:

```csharp
using System;
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;

public sealed class LeakTest
{
const int N = 30;

[Fact]
public void BackButtonBehavior_Command_Leaks()
{
var command = new TestCommand();
var control = CreateBackButtonBehaviorCohort(null, mitigate: false);
var leaky = CreateBackButtonBehaviorCohort(command, mitigate: false);
var mitigation = CreateBackButtonBehaviorCohort(command, mitigate: true);

AssertCohorts(control, leaky, mitigation, command);
}

[Fact]
public void TableView_Root_Leaks()
{
var root = new TableRoot();
var control = CreateTableViewCohort(null, mitigate: false);
var leaky = CreateTableViewCohort(root, mitigate: false);
var mitigation = CreateTableViewCohort(root, mitigate: true);

AssertCohorts(control, leaky, mitigation, root);
}

[Fact]
public void IndicatorView_ItemsSource_Leaks()
{
var items = new ObservableCollection();
var control = CreateIndicatorViewCohort(null, mitigate: false);
var leaky = CreateIndicatorViewCohort(items, mitigate: false);
var mitigation = CreateIndicatorViewCohort(items, mitigate: true);

AssertCohorts(control, leaky, mitigation, items);
}

[Fact]
public void Picker_ItemsSource_Leaks()
{
var items = new ObservableCollection();
var control = CreatePickerCohort(null, mitigate: false);
var leaky = CreatePickerCohort(items, mitigate: false);
var mitigation = CreatePickerCohort(items, mitigate: true);

AssertCohorts(control, leaky, mitigation, items);
}

static void AssertCohorts(
WeakReference[] control,
WeakReference[] leaky,
WeakReference[] mitigation,
object root)
{
ForceGc();

Assert.Equal(0, CountAlive(control));
Assert.Equal(N, CountAlive(leaky));
Assert.Equal(0, CountAlive(mitigation));
GC.KeepAlive(root);
}

[MethodImpl(MethodImplOptions.NoInlining)]
static WeakReference[] CreateBackButtonBehaviorCohort(TestCommand? command, bool mitigate)
{
var references = new WeakReference[N];
for (var i = 0; i < N; i++)
{
var payload = new Payload();
var behavior = new BackButtonBehavior { BindingContext = payload };
if (command is not null)
behavior.Command = command;
if (mitigate)
behavior.Command = null;
references[i] = new WeakReference(payload);
}
return references;
}

[MethodImpl(MethodImplOptions.NoInlining)]
static WeakReference[] CreateTableViewCohort(TableRoot? root, bool mitigate)
{
var references = new WeakReference[N];
for (var i = 0; i < N; i++)
{
var payload = new Payload();
var view = new TableView { BindingContext = payload };
if (root is not null)
view.Root = root;
if (mitigate)
view.Root = null!;
references[i] = new WeakReference(payload);
}
return references;
}

[MethodImpl(MethodImplOptions.NoInlining)]
static WeakReference[] CreateIndicatorViewCohort(ObservableCollection? items, bool mitigate)
{
var references = new WeakReference[N];
for (var i = 0; i < N; i++)
{
var payload = new Payload();
var view = new IndicatorView { BindingContext = payload };
if (items is not null)
view.ItemsSource = items;
if (mitigate)
view.ItemsSource = null;
references[i] = new WeakReference(payload);
}
return references;
}

[MethodImpl(MethodImplOptions.NoInlining)]
static WeakReference[] CreatePickerCohort(ObservableCollection? items, bool mitigate)
{
var references = new WeakReference[N];
for (var i = 0; i < N; i++)
{
var payload = new Payload();
var picker = new Picker { BindingContext = payload };
if (items is not null)
picker.ItemsSource = items;
if (mitigate)
picker.ItemsSource = null;
references[i] = new WeakReference(payload);
}
return references;
}

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();
}
}

sealed class Payload
{
readonly byte[] _bytes = new byte[1024 * 1024];
}

sealed class TestCommand : ICommand
{
public event EventHandler? CanExecuteChanged;

public bool CanExecute(object? parameter) => true;

public void Execute(object? parameter)
{
}

public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}
}
```

Run:

```bash
cd leakprobe
dotnet test --filter Picker_ItemsSource_Leaks --logger "console;verbosity=normal"
```

## Observed results

| Cohort | Alive after full GC | Retained payload |
|---|---:|---:|
| Control (no items source) | 0 / 30 | 0 MB |
| Mitigation (`ItemsSource = null`) | 0 / 30 | 0 MB |
| Leaky (shared source remains assigned) | 30 / 30 | 30 MB |

## Impact and condition

This is purely managed code and affects all platforms. The confirmed condition is a picker assigned an observable source and discarded before it ever acquires a handler; normal handler detachment does unsubscribe.

## Suggested fix

Use `WeakNotifyCollectionChangedProxy`, or defer the strong subscription until a handler exists while keeping display synchronization correct. This is a framework lifecycle edge-case leak, not the normal attached-picker teardown path; clearing `ItemsSource` is the current workaround.

> Generated by [Daily Memory Leak Hunter](https://github.com/dotnet/maui/actions/runs/32204388060) · gpt56 · 177.3 AIC · ⌖ 22.9 AIC · ⊞ 32.1K · [◷](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

Read src/Controls/src/Core/Picker/Picker.cs at lines 329-342, 372-383, and 411-420, then run the provided LeakTest.cs with `dotnet test --filter Picker_ItemsSource_Leaks`. Done means a picker discarded before acquiring a handler is collectible when its observable ItemsSource remains assigned, while normal handler detachment still unsubscribes correctly.

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
52/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.