dotnet / dotnet/maui

[leak-scan] VisualDiagnostics.VisualTreeChanged — static event strongly retains subscribers

Open
#38,449 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

agentic-workflows perf/memory-leak 💦
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:114 declares the static event.
  • src/Core/src/VisualDiagnostics/VisualDiagnostics.cs:116-119 invokes 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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.