Repository navigation
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
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-75defines the pairedOnAttached/OnDetachedlifecycle and invokes teardown only throughSendDetached().src/Controls/src/Core/VisualStateManager.cs:763-770processes additions toVisualState.StateTriggers, but has no corresponding removal callback.src/Controls/src/Core/VisualStateManager.cs:858-929implementsWatchAddList<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 mutatingVisualState.StateTriggerswithout 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
Fix memory leak when removing VisualState state triggers (#38444)
Fix memory leak when removing VisualState state triggers (#38444)
Fix memory leak when removing VisualState state triggers (#38444)
Fix memory leak when removing VisualState state triggers (#38444)
Fix memory leak when removing VisualState state triggers (#38444)
Fix memory leak when removing VisualState state triggers (#38444)
Fix memory leak when removing VisualState state triggers (#38444)
Fix memory leak when removing VisualState state triggers (#38444)
Fix memory leak when removing VisualState state triggers (#38444)
Fix memory leak when removing VisualState state triggers (#38444)
Fix memory leak when removing VisualState state triggers (#38444)
Fix memory leak when removing VisualState state triggers (#38444)
Fix memory leak when removing VisualState state triggers (#38444)
Fix memory leak when removing VisualState state triggers (#38444)
Fix memory leak when removing VisualState state triggers (#38444)