dotnet / dotnet/maui

[leak-scan] VisualStateGroupList.Clear — removed groups keep triggers attached

Open
#38,245 0 comments 0 reactions 0 assignees View on GitHub
agentic-workflows partner/syncfusion perf/memory-leak 💦
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.