dotnet / dotnet/maui

[leak-scan] IndicatorView.ItemsSource — collection subscription retains the view

Open
#37,633 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

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

Assigning a shared INotifyCollectionChanged source to IndicatorView.ItemsSource installs a strong CollectionChanged handler. A discarded view remains rooted by the source unless ItemsSource is explicitly cleared or replaced.

Retention path

shared ObservableCollection -> CollectionChanged invocation list -> IndicatorView.OnCollectionChanged -> IndicatorView -> BindingContext payload

src/Controls/src/Core/IndicatorView/IndicatorView.cs:57-59 routes every property change through ResetItemsSource; IndicatorView.cs:238-244 removes the old handler only on property replacement and strongly subscribes to the new collection. There is no unload teardown.

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 IndicatorView_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. It occurs when an observable items source outlives a discarded IndicatorView.

Suggested fix

Use WeakNotifyCollectionChangedProxy (already used elsewhere in Controls) or unsubscribe during view teardown. This is a framework lifecycle leak; clearing ItemsSource is the current application workaround.

Generated by Daily Memory Leak Hunter · gpt56 · 177.3 AIC · ⌖ 22.9 AIC · ⊞ 32.1K ·

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Read src/Controls/src/Core/IndicatorView/IndicatorView.cs, especially lines 57-59 and 238-244, to trace ItemsSource subscription and replacement. Run the supplied leakprobe test with the IndicatorView_ItemsSource_Leaks filter and compare the control, leaky, and mitigation cohorts. Done means discarded IndicatorView instances and their BindingContext payloads are no longer retained while ItemsSource remains assigned.

Written by the indexing model from the issue text.

Assessment

Tech stack
csharp
Domain
frontend, mobile-dev
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.