[leak-scan] AppActions.OnAppAction — static event strongly retains subscribers
- 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 — dotnet/maui workflow.** This finding was empirically confirmed against the shipped `Microsoft.Maui.Controls` 10.0.0 package on plain `net10.0`.
## Description
`AppActions.OnAppAction` forwards subscriptions to the process-wide `AppActions.Current` implementation. The implementation exposes a normal strong `AppActionActivated` event, so a page, view-model, or service instance that subscribes and is later discarded remains rooted until it explicitly unsubscribes.
## Retention path
`AppActions.currentImplementation (static) -> AppActionsImplementation.AppActionActivated -> subscriber delegate target -> transient subscriber -> payload`
- `src/Essentials/src/AppActions/AppActions.shared.cs:112-115` forwards `OnAppAction` add/remove to `Current.AppActionActivated`.
- `src/Essentials/src/AppActions/AppActions.shared.cs:118-124` stores that implementation in the static `currentImplementation` field.
- `src/Essentials/src/AppActions/AppActions.netstandard.tvos.watchos.macos.tizen.cs:18-20` shows the plain strong backing event used by this shipped-package `net10.0` repro.
## Repro
`leakprobe.csproj`:
```xml
net10.0
enable
false
```
`LeakTest.cs`:
```csharp
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using Microsoft.Maui.ApplicationModel;
using Xunit;
public sealed class LeakTest
{
const int N = 30;
[Fact]
public void AppActions_OnAppAction_Leaks()
{
var control = Create(Scenario.Control);
var leaky = Create(Scenario.Leaky);
var mitigation = Create(Scenario.Mitigation);
ForceGc();
var controlAlive = CountAlive(control);
var leakyAlive = CountAlive(leaky);
var mitigationAlive = CountAlive(mitigation);
Console.WriteLine(
$"RESULT AppActions.OnAppAction: Control={controlAlive}/{N}, " +
$"Leaky={leakyAlive}/{N}, Mitigation={mitigationAlive}/{N}");
Assert.Equal(0, controlAlive);
Assert.Equal(N, leakyAlive);
Assert.Equal(0, mitigationAlive);
}
[MethodImpl(MethodImplOptions.NoInlining)]
static WeakReference[] Create(Scenario scenario)
{
var references = new WeakReference[N];
for (var i = 0; i < N; i++)
{
var subject = new Subject();
if (scenario != Scenario.Control)
AppActions.OnAppAction += subject.OnAppAction;
if (scenario == Scenario.Mitigation)
AppActions.OnAppAction -= subject.OnAppAction;
references[i] = new WeakReference(subject);
}
return references;
}
static int CountAlive(IEnumerable references) =>
references.Count(reference => reference.IsAlive);
static void ForceGc()
{
for (var i = 0; i < 7; i++)
{
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
}
}
enum Scenario
{
Control,
Leaky,
Mitigation
}
sealed class Subject
{
readonly byte[] _payload = new byte[1024 * 1024];
public void OnAppAction(object? sender, AppActionEventArgs args) =>
GC.KeepAlive(_payload);
}
}
```
Run:
```bash
cd leakprobe
dotnet test --logger "console;verbosity=normal"
```
## Observed results
| Scenario | Alive after full GC | Retained payload |
|---|---:|---:|
| Control (never subscribed) | 0 / 30 | 0 MB |
| Mitigation (subscribe, then unsubscribe) | 0 / 30 | 0 MB |
| Leaky (subscription remains) | **30 / 30** | **30 MB** |
The xUnit fact passed: the leaky cohort retained every subject while control and mitigation fully released.
## Impact and condition
The retention is in shared managed Essentials code and therefore affects all platforms. It occurs when an instance subscriber is shorter-lived than the static `AppActions` facade and does not execute the matching `-=` cleanup. No app-action activation or device/emulator is required for the managed retention.
## Suggested fix
Back `OnAppAction`/`AppActionActivated` with weak event plumbing, or add a weak subscription layer at the static facade. The current application workaround is deterministic unsubscription during teardown.
## Scope note
This is partly a standard .NET event-lifetime usage footgun, but the static facade hides the process-lifetime publisher and makes accidental page/view-model retention easy. Framework hardening would align this API with other MAUI weak-event surfaces.
> Generated by [Daily Memory Leak Hunter](https://github.com/dotnet/maui/actions/runs/32972819743) · gpt56 · 296.2 AIC · ⌖ 11.9 AIC · ⊞ 43.7K · [◷](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/Essentials/src/AppActions/AppActions.shared.cs, especially the OnAppAction forwarding and static currentImplementation, then compare the strong event in src/Essentials/src/AppActions/AppActions.netstandard.tvos.watchos.macos.tizen.cs. Run the leakprobe LeakTest.cs repro with dotnet test. Done means instance subscribers no longer remain alive after subscription cleanup is omitted, while the control and mitigation scenarios still pass.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- csharp
- Domain
- mobile-dev
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 52/100