[leak-scan] BackButtonBehavior.Command — strong CanExecuteChanged subscription retains the behavior
- 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
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