[leak-scan] IndicatorView.ItemsSource — collection subscription retains the view
Nobody has claimed this yet.
- 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 shippedMicrosoft.Maui.Controls10.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
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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