Repository navigation
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:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Maui.Controls" Version="10.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
</Project>LeakTest.cs:
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<int>();
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<int>();
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<int>? 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<int>? 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<WeakReference> 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:
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 Β· gpt56 Β· 177.3 AIC Β· β 22.9 AIC Β· β 32.1K Β· β·
Activity
Fix Picker memory leak caused by ItemsSource collection subscription (#β¦
Fix Picker memory leak caused by ItemsSource collection subscription (#β¦
Fix Picker memory leak caused by ItemsSource collection subscription (#β¦
Fix Picker memory leak caused by ItemsSource collection subscription (#β¦
Fix Picker memory leak caused by ItemsSource collection subscription (#β¦
Fix Picker memory leak caused by ItemsSource collection subscription (#β¦
Fix Picker memory leak caused by ItemsSource collection subscription (#β¦
Fix Picker memory leak caused by ItemsSource collection subscription (#β¦
Fix Picker memory leak caused by ItemsSource collection subscription (#β¦
Fix Picker memory leak caused by ItemsSource collection subscription (#β¦
Fix Picker memory leak caused by ItemsSource collection subscription (#β¦
Fix Picker memory leak caused by ItemsSource collection subscription (#β¦
Fix Picker memory leak caused by ItemsSource collection subscription (#β¦
Fix Picker memory leak caused by ItemsSource collection subscription (#β¦
Fix Picker memory leak caused by ItemsSource collection subscription (#β¦
Fix Picker memory leak caused by ItemsSource collection subscription (#β¦
Fix Picker memory leak caused by ItemsSource collection subscription (#β¦
Fix Picker memory leak caused by ItemsSource collection subscription (#β¦
Fix Picker memory leak caused by ItemsSource collection subscription (#β¦
Fix Picker memory leak caused by ItemsSource collection subscription (#β¦
Fix Picker memory leak caused by ItemsSource collection subscription (#β¦
Fix Picker memory leak caused by ItemsSource collection subscription (#β¦
Fix Picker memory leak caused by ItemsSource collection subscription (#β¦