[leak-scan] VisualDiagnostics.VisualTreeChanged — static event strongly retains subscribers
Nobody has claimed this yet.
- Dominant language
- C#
- Stars
- 23.3k
- Forks
- 2k
- Avg merge
- 1d 14h
- Merged PRs (30d)
- 296
Description
[!IMPORTANT]
This issue was generated by the Daily Memory Leak Hunter AI workflow.
Description
VisualDiagnostics.VisualTreeChanged is a process-wide static event backed by a normal multicast delegate. Any transient diagnostic listener that does not explicitly unsubscribe remains strongly rooted for the process lifetime.
Retention path
VisualDiagnostics static type -> VisualTreeChanged delegate -> subscriber closure/target -> transient object`
src/Core/src/VisualDiagnostics/VisualDiagnostics.cs:114declares the static event.src/Core/src/VisualDiagnostics/VisualDiagnostics.cs:116-119invokes the stored delegate directly; there is no weak-event backing or lifecycle cleanup.
Standalone 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.Linq;
using System.Runtime.CompilerServices;
using Microsoft.Maui;
using Xunit;
public class LeakTest
{
const int N = 30;
[Fact]
public void VisualDiagnostics_VisualTreeChanged_Leaks()
{
var control = Create(Scenario.Control);
var leaky = Create(Scenario.Leaky);
var mitigation = Create(Scenario.Mitigation);
ForceGc();
Assert.Equal(0, Alive(control));
Assert.Equal(N, Alive(leaky));
Assert.Equal(0, Alive(mitigation));
}
[MethodImpl(MethodImplOptions.NoInlining)]
static WeakReference[] Create(Scenario scenario)
{
var references = new WeakReference[N];
for (var i = 0; i < N; i++)
{
var holder = new PayloadHolder();
references[i] = new WeakReference(holder);
EventHandler<VisualTreeChangeEventArgs> handler = (_, _) => holder.Touch();
if (scenario != Scenario.Control)
VisualDiagnostics.VisualTreeChanged += handler;
if (scenario == Scenario.Mitigation)
VisualDiagnostics.VisualTreeChanged -= handler;
}
return references;
}
static int Alive(WeakReference[] references) =>
references.Count(reference => reference.IsAlive);
static void ForceGc()
{
for (var i = 0; i < 7; i++)
{
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
}
}
enum Scenario { Control, Leaky, Mitigation }
sealed class PayloadHolder
{
readonly byte[] _payload = new byte[1024 * 1024];
public void Touch() => GC.KeepAlive(_payload);
}
}
Run:
dotnet test --logger "console;verbosity=normal"
Observed results
| Scenario | Alive after full GC | Retained payload |
|---|---|---|
| Control (no subscription) | 0 / 30 | 0 MB |
| Mitigation (explicit unsubscribe) | 0 / 30 | 0 MB |
| Leaky (subscription remains) | 30 / 30 | 30 MB |
The xUnit fact passes against shipped Microsoft.Maui.Controls 10.0.0 on plain net10.0.
Scope and suggested fix
Affected platforms: all; this is entirely managed code.
Condition: a transient subscriber attaches to this opt-in diagnostics event and does not explicitly unsubscribe.
Back the event with WeakEventManager, as done for other process-wide diagnostics events, while preserving the public event surface. This is partly an event-usage footgun, but a static framework diagnostics event can safely harden subscriber lifetime without requiring every tooling consumer to implement perfect teardown.
Generated by Daily Memory Leak Hunter · gpt56 · 203.9 AIC · ⌖ 21.1 AIC · ⊞ 29.6K · ◷
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start at src/Core/src/VisualDiagnostics/VisualDiagnostics.cs:114-119 and inspect how VisualTreeChanged is declared and invoked. Use the provided LeakTest.cs reproduction and run dotnet test to confirm the current retention behavior, then verify that the event no longer strongly retains unsubscribed subscribers while preserving its public event surface.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- csharp
- Domain
- devtools
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 74/100