Skip to content

[leak-scan] BackButtonBehavior.Command — strong CanExecuteChanged subscription retains the behavior #37635

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

Assigning a long-lived ICommand to BackButtonBehavior.Command installs a normal CanExecuteChanged handler. If the behavior is discarded without first clearing Command, the command retains the behavior and everything reachable from it. There is no lifecycle teardown for this subscription.

Retention path

shared ICommand -> CanExecuteChanged invocation list -> BackButtonBehavior.CanExecuteChanged -> BackButtonBehavior -> BindingContext payload

src/Controls/src/Core/Shell/BackButtonBehavior.cs:129-139 removes the handler only when the property changes, then adds the strong handler for the new command. No unload/detach path clears it.

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 BackButtonBehavior_Command_Leaks --logger "console;verbosity=normal"

Observed results

Cohort Alive after full GC Retained payload
Control (no command) 0 / 30 0 MB
Mitigation (Command = null) 0 / 30 0 MB
Leaky (shared command remains assigned) 30 / 30 30 MB

Impact and condition

This is purely managed code and affects all platforms. It occurs when a command outlives a discarded behavior and Command is not explicitly cleared.

Suggested fix

Use the existing weak command-subscription pattern (for example WeakCommandSubscription) or add deterministic behavior teardown that unsubscribes from CanExecuteChanged. This is a framework lifecycle leak; applications can mitigate it today by clearing Command before discarding the behavior.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    agentic-workflowspartner/syncfusionIssues / PR's with Syncfusion collaborationperf/memory-leak 💦Memory usage grows / objects live forever (sub: perf)s/triagedIssue has been revieweds/verifiedVerified / Reproducible Issue ready for Engineering Triage

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions