dotnet / dotnet/maui

[leak-scan] Routing.s_implicitPageRoutes — cancelled Shell push retains the page

Open
#38,256 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 14h
Merged PRs (30d)
296

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

Cancelling `Shell.Navigation.PushAsync(page)` leaves the pushed page in the process-wide implicit page route table. The navigation manager registers the page before raising the cancellable navigation event, then returns without clearing the route when cancellation is accepted. The static dictionary consequently retains the page and everything reachable from it.

## Retention path

`static Routing.s_implicitPageRoutes -> Page -> BindingContext -> 1 MB payload`

- [`src/Controls/src/Core/Routing.cs:13-15`](https://github.com/dotnet/maui/blob/main/src/Controls/src/Core/Routing.cs#L13-L15) declares the static page dictionary.
- [`src/Controls/src/Core/Routing.cs:28-35`](https://github.com/dotnet/maui/blob/main/src/Controls/src/Core/Routing.cs#L28-L35) inserts implicit pages into that dictionary.
- [`src/Controls/src/Core/Shell/ShellNavigationManager.cs:55-56`](https://github.com/dotnet/maui/blob/main/src/Controls/src/Core/Shell/ShellNavigationManager.cs#L55-L56) registers `PagePushing` before proposing navigation.
- [`src/Controls/src/Core/Shell/ShellNavigationManager.cs:73-85`](https://github.com/dotnet/maui/blob/main/src/Controls/src/Core/Shell/ShellNavigationManager.cs#L73-L85) returns on cancellation without removing the registered page.
- [`src/Controls/src/Core/Shell/ShellNavigationManager.cs:270-275`](https://github.com/dotnet/maui/blob/main/src/Controls/src/Core/Shell/ShellNavigationManager.cs#L270-L275) clears implicit routes only on the later successful-navigation event path.

## Standalone repro

`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 System.Threading.Tasks;
using Microsoft.Maui.Controls;
using Xunit;

public sealed class LeakTest
{
const int N = 30;

[Fact]
public async Task Routing_ImplicitPageRoutes_CancelledPush_Leaks()
{
var control = CreateControls();
var mitigation = await CreateCancelledPushSubjects(mitigate: true);
var leaky = await CreateCancelledPushSubjects(mitigate: false);

ForceGc();
Assert.Equal(0, Alive(control));
Assert.Equal(N, Alive(leaky));
Assert.Equal(0, Alive(mitigation));
}

[MethodImpl(MethodImplOptions.NoInlining)]
static List CreateControls()
{
var references = new List(N);
for (var i = 0; i < N; i++)
{
var payload = new Payload();
var page = new ContentPage { BindingContext = payload };
references.Add(new WeakReference(payload));
}
return references;
}

[MethodImpl(MethodImplOptions.NoInlining)]
static async Task> CreateCancelledPushSubjects(bool mitigate)
{
var references = new List(N);
for (var i = 0; i < N; i++)
{
var shell = CreateShell();
shell.Navigating += (_, args) => args.Cancel();

var payload = new Payload();
var page = new ContentPage { BindingContext = payload };
await shell.Navigation.PushAsync(page);
references.Add(new WeakReference(payload));

if (mitigate)
{
// Simulates the missing framework cleanup after cancellation.
typeof(Routing)
.GetMethod("ClearImplicitPageRoutes", BindingFlags.Static | BindingFlags.NonPublic)!
.Invoke(null, null);
}
}
return references;
}

static Shell CreateShell()
{
var content = new ShellContent { Content = new ContentPage() };
var section = new Tab();
section.Items.Add(content);
var item = new FlyoutItem();
item.Items.Add(section);
var shell = new Shell();
shell.Items.Add(item);
shell.CurrentItem = item;
item.CurrentItem = section;
section.CurrentItem = content;
return shell;
}

static int Alive(IEnumerable references) =>
references.Count(reference => reference.IsAlive);

static void ForceGc()
{
for (var i = 0; i < 7; i++)
{
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
}
}

sealed class Payload
{
readonly byte[] _bytes = new byte[1024 * 1024];
}
}
```

Run:

```bash
dotnet test --logger "console;verbosity=normal"
```

## Observed results

| Scenario | Alive after full GC | Retained payload |
|---|---:|---:|
| Control (page never pushed) | 0 / 30 | 0 MB |
| Mitigation (clear implicit routes after cancellation) | 0 / 30 | 0 MB |
| Leaky (cancelled push) | 30 / 30 | 30 MB |

The xUnit fact passed on .NET 10.0.11 using the shipped `Microsoft.Maui.Controls` 10.0.0 package.

## Impact

- **Affected platforms:** All platforms; the retention is entirely in managed Shell routing code.
- **Trigger:** An unregistered page is pushed through `Shell.Navigation.PushAsync(page)` and the navigation is cancelled.
- **Release condition:** The page remains retained until another path clears the global implicit-route table; if no later successful navigation occurs, retention is indefinite.

## Suggested fix

Remove the just-registered implicit page route, or clear/rebuild implicit routes, before returning from the cancelled-navigation branch. A targeted removal would avoid disturbing implicit routes belonging to an in-progress navigation.

**Scope note:** This is a framework lifecycle bug rather than a general event-subscription usage footgun: the framework inserts the page into its private static table before exposing cancellation, but does not roll that insertion back when cancellation occurs.

> Generated by [Daily Memory Leak Hunter](https://github.com/dotnet/maui/actions/runs/34034430187) · gpt56 · 405.5 AIC · ⌖ 14.3 AIC · ⊞ 32.2K · [◷](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/Routing.cs and src/Controls/src/Core/Shell/ShellNavigationManager.cs, especially the registration, cancellation, and successful-navigation paths cited in the issue. Run the standalone xUnit repro with dotnet test, then add or adapt coverage so a cancelled Shell.Navigation.PushAsync(page) does not retain the page after a full GC while the existing control and mitigation cases still pass.

Written by the indexing model from the issue text.

Assessment

Tech stack
csharp
Domain
frontend, mobile
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
74/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.