dotnet / dotnet/maui

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

Open
#37,635 1 comment 0 reactions 0 assignees View on GitHub
agentic-workflows partner/syncfusion perf/memory-leak 💦 s/triaged s/verified
Dominant language
C#
Stars
23.3k
Forks
2k
Avg merge
1d 15h
Merged PRs (30d)
290

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 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`:

```xml


net10.0
enable
false






```

`LeakTest.cs`:

```csharp
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();
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();
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? 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? 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 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:

```bash
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](https://github.com/dotnet/maui/actions/runs/32204388060) · gpt56 · 177.3 AIC · ⌖ 22.9 AIC · ⊞ 32.1K · [◷](https://github.com/search?q=repo%3Adotnet%2Fmaui+is%3Aissue+%22gh-aw-workflow-call-id%3A+dotnet%2Fmaui%2Fdaily-leak-hunter%22&type=issues)

Contributor guide

Open the contributing guide

Research direction

Start with src/Controls/src/Core/Shell/BackButtonBehavior.cs:129-139 and compare its command subscription with the existing WeakCommandSubscription pattern. Run the provided leakprobe test with the BackButtonBehavior_Command_Leaks filter. Done means the shared command no longer retains discarded behaviors while the existing command behavior remains functional.

Written by the indexing model from the issue text.

Assessment

Tech stack
csharp
Domain
mobile-dev
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
72/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.