Skip to content

[leak-scan] VisualState.StateTriggers β€” removal skips trigger detachmentΒ #38243

Closed

Description

@github-actions

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

Clearing an attached VisualState.StateTriggers collection removes the trigger from the collection without calling StateTriggerBase.SendDetached(). A trigger that subscribed to a shared managed publisher in OnAttached() therefore remains subscribed after it is removed, retaining the trigger and anything it owns.

The same removal-notification gap exists for Remove, RemoveAt, and index replacement in the underlying list. The repro uses Clear() as the canonical case.

Retention path

shared publisher -> event delegate -> removed StateTriggerBase instance -> page-local payload

  • src/Controls/src/Core/StateTriggerBase.cs:52-75 defines the paired OnAttached/OnDetached lifecycle and invokes teardown only through SendDetached().
  • src/Controls/src/Core/VisualStateManager.cs:763-770 processes additions to VisualState.StateTriggers, but has no corresponding removal callback.
  • src/Controls/src/Core/VisualStateManager.cs:858-929 implements WatchAddList<T>; Clear, Remove, RemoveAt, and the indexer setter mutate the list without notifying the owner, so removed triggers are not detached.

Standalone repro

The reflection call only simulates the framework's internal trigger-attachment lifecycle without creating a platform window; collection mutation and retention use the public managed APIs.

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.Collections.Generic;
using System.Linq;
using System.Reflection;
using Microsoft.Maui.Controls;
using Xunit;

public sealed class LeakTest
{
	const int N = 30;

	enum Scenario
	{
		Control,
		Leaky,
		Mitigation
	}

	[Fact]
	public void VisualState_StateTriggers_Clear_Leaks()
	{
		var control = CreateVictims(Scenario.Control);
		var leaky = CreateVictims(Scenario.Leaky);
		var mitigation = CreateVictims(Scenario.Mitigation);

		for (var i = 0; i < 7; i++)
		{
			GC.Collect();
			GC.WaitForPendingFinalizers();
			GC.Collect();
		}

		Assert.Equal(0, CountAlive(control));
		Assert.Equal(N, CountAlive(leaky));
		Assert.Equal(0, CountAlive(mitigation));
	}

	[System.Runtime.CompilerServices.MethodImpl(
		System.Runtime.CompilerServices.MethodImplOptions.NoInlining)]
	static List<WeakReference<byte[]>> CreateVictims(Scenario scenario)
	{
		var references = new List<WeakReference<byte[]>>(N);
		var sendAttached = typeof(StateTriggerBase).GetMethod(
			"SendAttached", BindingFlags.Instance | BindingFlags.NonPublic)!;
		var sendDetached = typeof(StateTriggerBase).GetMethod(
			"SendDetached", BindingFlags.Instance | BindingFlags.NonPublic)!;

		for (var i = 0; i < N; i++)
		{
			var payload = new byte[1024 * 1024];
			references.Add(new WeakReference<byte[]>(payload));

			if (scenario == Scenario.Control)
				continue;

			var trigger = new SharedPublisherTrigger(payload);
			var state = new VisualState { Name = "Probe" };
			state.StateTriggers.Add(trigger);
			var group = new VisualStateGroup();
			group.States.Add(state);
			VisualStateManager.SetVisualStateGroups(
				new Label(), new VisualStateGroupList { group });

			sendAttached.Invoke(trigger, null);
			if (scenario == Scenario.Mitigation)
				sendDetached.Invoke(trigger, null);

			state.StateTriggers.Clear();
		}

		return references;
	}

	static int CountAlive(IEnumerable<WeakReference<byte[]>> references)
		=> references.Count(reference => reference.TryGetTarget(out _));

	sealed class SharedPublisherTrigger : StateTriggerBase
	{
		readonly byte[] _payload;

		public SharedPublisherTrigger(byte[] payload) => _payload = payload;

		protected override void OnAttached() => SharedPublisher.Changed += OnChanged;

		protected override void OnDetached() => SharedPublisher.Changed -= OnChanged;

		void OnChanged(object? sender, EventArgs e) => GC.KeepAlive(_payload);
	}

	static class SharedPublisher
	{
		public static event EventHandler? Changed;
	}
}

Run:

dotnet test --logger "console;verbosity=normal"

Observed results

Scenario Alive after full GC Retained payload
Control (trigger never attached) 0/30 0 MB
Mitigation (SendDetached before Clear) 0/30 0 MB
Leaky (Clear while attached) 30/30 30 MB

Scope

  • Affected platforms: All; the collection and trigger lifecycle are cross-platform managed code.
  • Condition: A trigger has subscribed to a long-lived publisher from OnAttached(), then is removed by mutating VisualState.StateTriggers without first detaching it.
  • Assessment: This is a framework lifecycle bug rather than merely a usage footgun: collection removal should pair the framework-issued attachment with detachment.

Suggested fix

Make VisualState.StateTriggers removal operations notify VisualState of removed triggers and call SendDetached() before clearing their VisualState association. Cover Clear, Remove, RemoveAt, and index replacement so every collection mutation preserves the attach/detach pairing.

Generated by Daily Memory Leak Hunter Β· gpt56 Β· 707.4 AIC Β· βŒ– 10.7 AIC Β· ⊞ 32.5K Β· β—·

Activity

  1. added a commit that references this issue on Sep 18, 2026
    593b1ee
  2. added this to the .NET 10 SR12 milestone on Sep 18, 2026
  3. added 8 commits that reference this issue on Sep 18, 2026
    01b4d6a
    85268f2
    ef4e45f
    d37eb21
    fd929e4
    aee4597
    d013017
    d93907f
  4. added 6 commits that reference this issue on Oct 13, 2026
    732d970
    7f1cf76
    3b70494
    0d0c8bf
    8f3d670
    a1dd2e7
  5. locked and limited conversation to collaborators on Nov 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions