dotnet / dotnet/maui

[leak-scan] MultiBinding.Apply — rooted binding strongly retains its target

Open
#38,017 0 comments 0 reactions 0 assignees View on GitHub
agentic-workflows partner/syncfusion perf/memory-leak 💦
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.