[leak-scan] TransformGroup.Children — shared collection strongly retains the transform group
- 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
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