[leak-scan] MultiBinding.Apply — rooted binding strongly retains its target
- Dominant language
- C#
- Stars
- 23.3k
- Forks
- 2k
- Avg merge
- 1d 10h
- Merged PRs (30d)
- 297
Description
> [!IMPORTANT]
> **AI-generated by the Daily Memory Leak Hunter — dotnet/maui workflow.** This finding was empirically confirmed against the shipped `Microsoft.Maui.Controls` 10.0.0 package on plain `net10.0`.
## Description
A long-lived or shared `MultiBinding` strongly retains the last target to which it is applied. `MultiBinding.Apply` stores the target in `_targetObject` and also parents `_proxyObject` to the target. Removing the target from the visual tree does not unapply the binding; explicitly calling `RemoveBinding` releases it.
This scanner previously reported the behavior in #37243, but that issue is closed and the shipped 10.0.0 package still reproduces it.
## Retention path
`rooted MultiBinding -> MultiBinding._targetObject -> transient Label -> 1 MB payload`
There is a parallel path through `MultiBinding._proxyObject -> ProxyElement.Parent -> transient Label`.
- `src/Controls/src/Core/MultiBinding.cs:19-21` declares the strong target and proxy fields.
- `src/Controls/src/Core/MultiBinding.cs:145-159` assigns both references in `Apply`.
- `src/Controls/src/Core/MultiBinding.cs:242-253` clears them only in `Unapply`.
## Standalone repro
`leakprobe.csproj`:
```xml
net10.0
enable
false
```
`LeakTest.cs`:
```csharp
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using Microsoft.Maui.Controls;
using Xunit;
public sealed class LeakTest
{
const int N = 30;
[Fact]
public void MultiBinding_Apply_Leaks()
{
var control = Create(Scenario.Control);
var leaky = Create(Scenario.Leaky);
var mitigation = Create(Scenario.Mitigation);
ForceGc();
Assert.Equal(0, control.References.Count(x => x.IsAlive));
Assert.Equal(N, leaky.References.Count(x => x.IsAlive));
Assert.Equal(0, mitigation.References.Count(x => x.IsAlive));
GC.KeepAlive(control.Roots);
GC.KeepAlive(leaky.Roots);
GC.KeepAlive(mitigation.Roots);
}
[MethodImpl(MethodImplOptions.NoInlining)]
static ProbeResult Create(Scenario scenario)
{
var references = new List(N);
var roots = new List(N);
for (var i = 0; i < N; i++)
{
var label = new PayloadLabel();
var binding = new MultiBinding { StringFormat = "{0}" };
binding.Bindings.Add(new Binding(".", source: "value"));
roots.Add(binding);
if (scenario != Scenario.Control)
label.SetBinding(Label.TextProperty, binding);
if (scenario == Scenario.Mitigation)
label.RemoveBinding(Label.TextProperty);
references.Add(new WeakReference(label.Payload));
}
return new ProbeResult(references, roots);
}
[MethodImpl(MethodImplOptions.NoInlining)]
static void ForceGc()
{
for (var i = 0; i < 7; i++)
{
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
}
}
sealed class PayloadLabel : Label
{
public byte[] Payload { get; } = new byte[1024 * 1024];
}
sealed record ProbeResult(
IReadOnlyCollection References,
IReadOnlyCollection Roots);
enum Scenario
{
Control,
Leaky,
Mitigation
}
}
```
Run:
```bash
dotnet test --logger "console;verbosity=normal"
```
## Observed results
| Scenario | Alive after full GC | Retained payload |
|---|---:|---:|
| Control (binding retained but never applied) | 0 / 30 | 0 MB |
| Mitigation (`RemoveBinding`) | 0 / 30 | 0 MB |
| Leaky (applied binding remains externally rooted) | 30 / 30 | 30 MB |
The xUnit fact passed against `Microsoft.Maui.Controls` 10.0.0.
## Scope and affected platforms
This is entirely managed cross-platform code and therefore affects all platforms.
The non-default condition is externally retaining or sharing the `MultiBinding` after its target should be collectible. Normal target-owned binding lifetimes do not create an independent root.
## Suggested fix
Store the target weakly, or ensure a target lifecycle teardown invokes `Unapply` even while the `MultiBinding` remains externally rooted. The proxy parent path must be cleared at the same time.
**Scope note:** this is primarily a shared-binding lifetime footgun rather than a leak in the common target-owned binding case, but the framework can harden it because `MultiBinding` silently stores strong target references until explicit unapplication.
> Generated by [Daily Memory Leak Hunter](https://github.com/dotnet/maui/actions/runs/33347084022) · gpt56 · 408.1 AIC · ⌖ 11.7 AIC · ⊞ 32.2K · [◷](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/MultiBinding.cs, especially the target and proxy fields, Apply, and Unapply. Run the standalone LeakTest.cs reproduction with dotnet test and compare the control, leaky, and mitigation scenarios. Done means an externally rooted applied MultiBinding no longer keeps its removed target or payload alive, while the existing binding behavior remains intact.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- csharp
- Domain
- frontend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 68/100