[leak-scan] ShellContent.Content — shared Page.PropertyChanged retains prior ShellContent instances
- Dominant language
- C#
- Stars
- 23.3k
- Forks
- 2k
- Avg merge
- 1d 15h
- Merged PRs (30d)
- 290
Description
> [!IMPORTANT]
> This issue was generated by AI in the **Daily Memory Leak Hunter** workflow.
## Description
Assigning the same long-lived `Page` to multiple transient `ShellContent.Content` properties leaves every prior `ShellContent` strongly subscribed to the page. `ContentProperty` invokes `OnContentChanged` (`src/Controls/src/Core/Shell/ShellContent.cs:27-28`), which stores the page as `ContentCache` (`ShellContent.cs:318-334`). Adding it as a logical child invokes `OnChildAdded`, which attaches `page.PropertyChanged += OnPagePropertyChanged` (`ShellContent.cs:185-191`). The handler is removed only when `OnChildRemoved` runs (`ShellContent.cs:195-201`); merely dropping the `ShellContent` does not tear it down.
## Retention path
`long-lived shared ContentPage -> PropertyChanged delegate invocation list -> ShellContent.OnPagePropertyChanged -> transient ShellContent (and its payload)`
Clearing `Content` invokes the replacement/removal path and releases the subscription.
## Standalone repro
The repro targets plain `net10.0` and the shipped `Microsoft.Maui.Controls` 10.0.0 package; no workload, source build, or device is needed.
`leakprobe.csproj`:
```xml
net10.0
enable
false
```
`LeakTest.cs` (run `ShellContent_Content_Leaks`; the companion fact demonstrates a separate issue):
```csharp
using System;
using System.Linq;
using System.Runtime.CompilerServices;
using Microsoft.Maui.Controls;
using Xunit;
public class LeakTest
{
const int N = 30;
static readonly ContentPage SharedPage = new();
static readonly BoxView SharedView = new();
[Fact]
public void ShellContent_Content_Leaks()
{
var control = CreateShellContents(Scenario.Control);
var leaky = CreateShellContents(Scenario.Leaky);
var mitigation = CreateShellContents(Scenario.Mitigation);
ForceGc();
Assert.Equal(0, Alive(control));
Assert.Equal(N, Alive(leaky));
Assert.Equal(0, Alive(mitigation));
}
[Fact]
public void SwipeView_Content_Leaks()
{
var control = CreateSwipeViews(Scenario.Control);
var leaky = CreateSwipeViews(Scenario.Leaky);
var mitigation = CreateSwipeViews(Scenario.Mitigation);
ForceGc();
Assert.Equal(0, Alive(control));
Assert.Equal(N, Alive(leaky));
Assert.Equal(0, Alive(mitigation));
}
[MethodImpl(MethodImplOptions.NoInlining)]
static WeakReference[] CreateShellContents(Scenario scenario)
{
var references = new WeakReference[N];
for (var i = 0; i < N; i++)
{
var subject = new PayloadShellContent();
if (scenario != Scenario.Control)
subject.Content = SharedPage;
if (scenario == Scenario.Mitigation)
subject.Content = null;
references[i] = new WeakReference(subject);
}
return references;
}
[MethodImpl(MethodImplOptions.NoInlining)]
static WeakReference[] CreateSwipeViews(Scenario scenario)
{
var references = new WeakReference[N];
for (var i = 0; i < N; i++)
{
var subject = new PayloadSwipeView();
if (scenario != Scenario.Control)
subject.Content = SharedView;
if (scenario == Scenario.Mitigation)
subject.Content = null;
references[i] = new WeakReference(subject);
}
return references;
}
[MethodImpl(MethodImplOptions.NoInlining)]
static void ForceGc()
{
for (var i = 0; i < 7; i++)
{
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
}
}
static int Alive(WeakReference[] references) => references.Count(reference => reference.IsAlive);
enum Scenario
{
Control,
Leaky,
Mitigation
}
sealed class PayloadShellContent : ShellContent
{
readonly byte[] _payload = new byte[1024 * 1024];
}
sealed class PayloadSwipeView : SwipeView
{
readonly byte[] _payload = new byte[1024 * 1024];
}
}
```
Run:
```shell
dotnet test --logger "console;verbosity=normal"
```
## Observed results
| Scenario | Alive after full GC | Retained payload |
|---|---:|---:|
| Control (no shared page) | 0 / 30 | 0 MB |
| Mitigation (`Content = null`) | 0 / 30 | 0 MB |
| Leaky (shared page remains assigned) | 30 / 30 | 30 MB |
The xUnit fact passed against package version 10.0.0.
## Scope and suggested fix
**Affected platforms:** all; the retention is in cross-platform managed code.
**Condition:** a long-lived/shared `Page` instance is assigned to transient `ShellContent` instances and `Content` is not explicitly cleared. Sharing page instances is atypical, so this is primarily a usage footgun that the framework can harden.
Use a weak property-changed proxy for the child subscription, or ensure the old owner is detached when a page is reparented. Explicitly setting `Content = null` is an effective application-level mitigation.
> Generated by [Daily Memory Leak Hunter](https://github.com/dotnet/maui/actions/runs/31258944956) · gpt56 · 241.9 AIC · ⌖ 26 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
Read src/Controls/src/Core/Shell/ShellContent.cs, especially OnContentChanged, OnChildAdded, and OnChildRemoved, then run the standalone LeakTest.cs with dotnet test. Add regression coverage for shared Page assignment and verify transient ShellContent instances are collectible without explicitly clearing Content, while the existing mitigation remains collectible.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- csharp
- Domain
- desktop-dev, frontend, mobile-dev
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 70/100