dotnet / dotnet/maui

[leak-scan] TransformGroup.Children — shared collection strongly retains the transform group

Open
#37,583 0 comments 0 reactions 0 assignees View on GitHub
agentic-workflows perf/memory-leak 💦
Dominant language
C#
Stars
23.3k
Forks
2k
Avg merge
1d 15h
Merged PRs (30d)
290

Description

> [!IMPORTANT]
> AI-generated by the **Daily Memory Leak Hunter** workflow.

## Description

Assigning a long-lived `TransformCollection` to a transient `TransformGroup.Children` strongly retains the group. `AttachCollection` directly subscribes the group to `CollectionChanged`; cleanup only runs if `Children` is replaced.

**Retention path:** shared `TransformCollection` -> `CollectionChanged` invocation list -> `TransformGroup.OnChildrenCollectionChanged` -> transient group -> 1 MB payload.

Source: `src/Controls/src/Core/Shapes/TransformGroup.cs:17-18` declares the callback; `TransformGroup.cs:45-60` attaches the shared collection; `TransformGroup.cs:68-77` detaches only on property replacement. There is no unload/navigation teardown.

## Standalone repro

`leakprobe.csproj`:

```xml

net10.0enablefalse





```

`LeakTest.cs`:

```csharp
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using Microsoft.Maui.Controls.Shapes;
using Xunit;

public class LeakTest
{
const int N = 30;
[Fact]
public void TransformGroup_Children_Leaks()
{
var shared = new TransformCollection();
var control = Create(shared, 0); var leaky = Create(shared, 1); var mitigation = Create(shared, 2);
ForceGc();
Assert.Equal(0, Alive(control)); Assert.Equal(N, Alive(leaky)); Assert.Equal(0, Alive(mitigation));
GC.KeepAlive(shared);
}
[MethodImpl(MethodImplOptions.NoInlining)]
static List Create(TransformCollection shared, int scenario)
{
var refs = new List();
for (var i = 0; i < N; i++)
{
var subject = new TransformGroup { BindingContext = new byte[1024 * 1024] };
if (scenario != 0) subject.Children = shared;
if (scenario == 2) subject.Children = new TransformCollection();
refs.Add(new WeakReference(subject));
}
return refs;
}
static int Alive(IEnumerable refs) { var n = 0; foreach (var r in refs) if (r.IsAlive) n++; return n; }
static void ForceGc() { for (var i = 0; i < 7; i++) { GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); } }
}
```

Run: `dotnet test --logger "console;verbosity=normal"`

## Observed results

| Scenario | Alive | Retained payload |
|---|---:|---:|
| Control | 0/30 | 0 MB |
| Mitigation (replace `Children`) | 0/30 | 0 MB |
| Leaky (shared collection remains assigned) | 30/30 | 30 MB |

This managed path affects all platforms. It requires assigning a shared/long-lived `TransformCollection` and not replacing it before discarding the group.

**Suggested fix:** subscribe through a weak collection-changed proxy or add lifecycle teardown. **Scope:** a framework-hardening opportunity; sharing the collection works functionally but has a hidden lifetime requirement.

> Generated by [Daily Memory Leak Hunter](https://github.com/dotnet/maui/actions/runs/32140707109) · gpt56 · 134.2 AIC · ⌖ 23.5 AIC · ⊞ 32.1K · [◷](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 src/Controls/src/Core/Shapes/TransformGroup.cs, especially the callback and Children attachment and detachment paths described in the issue. Run the supplied LeakTest.cs with dotnet test to reproduce the retention behavior, then determine a lifecycle-safe resolution and add or update regression coverage. Done means transient TransformGroup instances are collectible while a shared TransformCollection remains assigned.

Written by the indexing model from the issue text.

Assessment

Tech stack
csharp
Domain
desktop-dev, frontend, mobile-dev
Issue type
Bug
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
45/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.