dotnet / dotnet/maui

[leak-scan] ShellContent.Content — shared Page.PropertyChanged retains prior ShellContent instances

Open
#37,217 2 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 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

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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.