Skip to content

[leak-scan] Picker.ItemsSource β€” pre-handler collection subscription retains the pickerΒ #37636

Closed

Description

@github-actions

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

  1. added a commit that references this issue on Aug 31, 2026
    c944645
  2. added this to the .NET 10 SR11 milestone on Aug 31, 2026
  3. added 10 commits that reference this issue on Sep 2, 2026
    c51a526
    0b2fcfa
    879ad18
    0f79594
    fb9ff4d
    4f30db6
    289ff15
    0de03e0
    9b133b3
    e3bdff2
  4. added 6 commits that reference this issue on Sep 27, 2026
    2d9059d
    ddced7f
    562fab9
    924e2f9
    17b8d22
    9f59a26
  5. added 6 commits that reference this issue on Oct 13, 2026
    979b1a9
    938ada1
    2d24f82
    13d2f1d
    cbd59c9
    d24c80d
  6. locked and limited conversation to collaborators on Nov 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions