[leak-scan] VisualStateGroupList.Clear — removed groups keep triggers attached
- 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
Clearing an attached `VisualStateGroupList` removes groups without detaching the state triggers nested inside them. A trigger subscribed to a shared managed publisher in `OnAttached()` therefore stays subscribed and retained after its group is removed.
## Retention path
`shared publisher -> event delegate -> StateTriggerBase in removed VisualStateGroup -> page-local payload`
- `src/Controls/src/Core/VisualStateManager.cs:350-358` clears the group list and removes only `StatesChanged` handlers; it does not detach nested triggers.
- `src/Controls/src/Core/VisualStateManager.cs:373-419` similarly omits trigger teardown for `Remove`, `RemoveAt`, and index replacement.
- `src/Controls/src/Core/StateTriggerBase.cs:52-75` unsubscribes only when `SendDetached()` reaches `OnDetached()`.
## Standalone repro
The reflection calls simulate the framework's internal attach/detach lifecycle without requiring a platform window.
`leakprobe.csproj`:
```xml
net10.0
enable
false
```
`LeakTest.cs`:
```csharp
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Runtime.CompilerServices;
using Microsoft.Maui.Controls;
using Xunit;
public sealed class LeakTest
{
const int N = 30;
enum Scenario { Control, Leaky, Mitigation }
[Fact]
public void VisualStateGroupList_Clear_Leaks()
{
var control = Create(Scenario.Control);
var leaky = Create(Scenario.Leaky);
var mitigation = Create(Scenario.Mitigation);
for (var i = 0; i < 7; i++)
{
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
}
Assert.Equal(0, Alive(control));
Assert.Equal(N, Alive(leaky));
Assert.Equal(0, Alive(mitigation));
}
[MethodImpl(MethodImplOptions.NoInlining)]
static List> Create(Scenario scenario)
{
var result = new List>();
var attach = typeof(StateTriggerBase).GetMethod(
"SendAttached", BindingFlags.Instance | BindingFlags.NonPublic)!;
var detach = typeof(StateTriggerBase).GetMethod(
"SendDetached", BindingFlags.Instance | BindingFlags.NonPublic)!;
for (var i = 0; i < N; i++)
{
var payload = new byte[1024 * 1024];
result.Add(new(payload));
if (scenario == Scenario.Control)
continue;
var trigger = new ProbeTrigger(payload);
var state = new VisualState { Name = "Probe" };
state.StateTriggers.Add(trigger);
var group = new VisualStateGroup();
group.States.Add(state);
var groups = new VisualStateGroupList { group };
VisualStateManager.SetVisualStateGroups(new Label(), groups);
attach.Invoke(trigger, null);
if (scenario == Scenario.Mitigation)
detach.Invoke(trigger, null);
groups.Clear();
}
return result;
}
static int Alive(IEnumerable> items)
=> items.Count(item => item.TryGetTarget(out _));
sealed class ProbeTrigger(byte[] payload) : StateTriggerBase
{
protected override void OnAttached() => Publisher.Changed += Changed;
protected override void OnDetached() => Publisher.Changed -= Changed;
void Changed(object? sender, EventArgs e) => GC.KeepAlive(payload);
}
static class Publisher
{
public static event EventHandler? Changed;
}
}
```
Run `dotnet test --logger "console;verbosity=normal"`.
## Observed results
| Scenario | Alive after full GC | Retained payload |
|---|---:|---:|
| Control | 0/30 | 0 MB |
| Mitigation (`SendDetached` before `Clear`) | 0/30 | 0 MB |
| Leaky (`VisualStateGroupList.Clear`) | 30/30 | 30 MB |
## Scope and suggested fix
This affects all platforms when an attached group's trigger subscribes to a long-lived publisher and the group is removed through list mutation. It is a framework lifecycle bug: `VisualStateGroupList` removal operations should detach all triggers nested in each removed group. Cover `Clear`, `Remove`, `RemoveAt`, replacement by duplicate-name `Add`, and index replacement.
> Generated by [Daily Memory Leak Hunter](https://github.com/dotnet/maui/actions/runs/34003449717) · gpt56 · 707.4 AIC · ⌖ 10.7 AIC · ⊞ 32.5K · [◷](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
Research direction
Start with VisualStateManager.cs lines 350-358 and 373-419, then inspect StateTriggerBase.cs lines 52-75 to understand the attach and detach lifecycle. Run the supplied leakprobe.csproj test with dotnet test and add focused coverage for Clear, Remove, RemoveAt, duplicate-name Add, and index replacement. Done means removed groups no longer retain their nested triggers or subscribed payloads.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- csharp
- Domain
- frontend, mobile-dev
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 68/100