dotnet / dotnet/maui

[leak-scan] AppActions.OnAppAction — static event strongly retains subscribers

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

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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.