[leak-scan] Routing.s_implicitPageRoutes — cancelled Shell push retains the page
- 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
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