[leak-scan] Picker.ItemsSource — pre-handler collection subscription retains the picker
- 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
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