dotnet / dotnet/android

[Critical Android Bug] Android views leak via JNI Global Reference even after `DisconnectHandlers()` and explicit peer `Dispose()`

Open
#12,516 11 comments 0 reactions 1 assignee Claimed by @jonathanpeppers View on GitHub
Area: App Runtime Area: Performance t/bug
Dominant language
C#
Stars
2.1k
Forks
579
Avg merge
1d 20h
Merged PRs (30d)
257

Description

### Description

## Description

When repeatedly displaying and destroying a `ContentView` that contains custom `View` handlers (specifically a `ViewHandler` whose `TPlatform` is a custom `LinearLayout` subclass), the native Android `View` instances **are never garbage-collected**, even after:

1. Recursive `MauiView.DisconnectHandlers()` from the root
2. Explicit `Java.Lang.Object.Dispose()` on the platform view (deferred via `Handler.MainLooper.PostDelayed` to avoid layout-pass race conditions)
3. `View.setBackground(null)` to break Drawable callback cycles
4. Multiple `GC.Collect()` + `GC.WaitForPendingFinalizers()` cycles
5. ART full GC via `adb shell am send-trim-memory COMPLETE`

Eclipse MAT analysis of the heap dump shows that each leaked native view has **GC root: JNI Global**, indicating the Mono runtime peer is never released — even when `Dispose()` was demonstrably called (verified via diagnostic `Interlocked` counters: `scheduled == completed`).

## Reproduction steps

1. Create a `ViewHandler` where `CustomLinearLayout : LinearLayout` is a custom Java type registered via `[Register]`.
2. In `ConnectHandler`, inflate the platform view and obtain inner views via `_root.GetChildAt(i) as ImageView`.
3. Open a `ContentView` page that creates ~15-20 instances of this handler in a `Grid`.
4. Close the page (via standard MAUI navigation), call `view.DisconnectHandlers()` recursively, run `GC.Collect()`.
5. Repeat 10×.
6. Force a full ART GC: `adb shell am send-trim-memory COMPLETE`.
7. Take a heap dump: `adb shell am dumpheap /data/local/tmp/heap.hprof`.

## Expected behavior

After `DisconnectHandlers()` + GC + Force ART GC, the native Java `View` instances should be eligible for collection. `dumpsys meminfo` Views count should return to baseline.

## Actual behavior

After 10 cycles + Force GC:
- `dumpsys meminfo Views`: **1098** (baseline ~200, so **~900 zombie native Views**)
- Heap dump shows **~170 zombie `CustomLinearLayout` instances** with **`JNI Global` GC root path**
- Each zombie holds ~5-10 child views (`MaterialTextView`, `AppCompatImageView`, etc.) via `mChildren`
- Per-cycle leak: **~86 native Views, +1.5 MB PSS**

## Diagnostic evidence

### Live count of managed objects (Interlocked counters in ctor/finalizer)

After 10 cycles:
- `Form` (ContentView): live oscillates 1↔2 ✓ correctly GC'd
- `EditableForm` (ContentView): live oscillates 2↔4 ✓
- Custom toolbar `ContentView`: live oscillates 0↔1 ✓
- All managed C# objects ARE finalized correctly.

### Eclipse MAT — Path to GC Roots (excluding weak/soft/phantom refs) for one zombie `CustomLinearLayout`:

```
CustomLinearLayout @ 0x12d502e8 GC root: JNI Global
↓ mChildren View[]
└ MaterialTextView @ 0x...
└ MaterialTextView @ 0x...
└ AppCompatImageView @ 0x...
```

The **only** GC root path is `JNI Global` — meaning Mono runtime holds a global JNI reference that was never released.

### Histogram of leaked instances after 10 cycles + Force GC

| Class | Instance count |
|---|---|
| `android.graphics.RenderNode` | 1414 |
| `android.view.ViewAnimationHostBridge` | 1414 |
| `androidx.appcompat.widget.AppCompatBackgroundHelper` | 626 |
| `androidx.appcompat.widget.AppCompatTextHelper` | 455 |
| `androidx.emoji2.viewsintegration.EmojiTextViewHelper` (5 variants) | 454 each |
| `com.google.android.material.textview.MaterialTextView` | 342 |
| `crc6431bef388e7f7f9bd.CustomLinearLayout` (custom platform view) | 171 |
| `mono.android.view.View_OnFocusChangeListenerImplementor` | 280 |

### Diagnostic counter proof that `Dispose()` was called

```
FSK - EnhancedBtn dispose scheduled, total=156
...
FSK - EnhancedBtn dispose completed, total=156
```

156 platform-view peers explicitly called `Java.Lang.Object.Dispose()`, yet 171 of them remain in the heap with `JNI Global` GC root.

## Workarounds attempted (none reduced leak)

| Attempt | Result |
|---|---|
| `view.DisconnectHandlers()` recursively from root | No effect on count |
| `_root.Background = null` (break Drawable.callback cycle) | No effect |
| `_image.SetImageDrawable(null)` | No effect |
| `_root.OnFocusChangeListener = null` | No effect |
| `_root.Dispose()` synchronously in `DisconnectHandler` | **Crashes** with `JNI ERROR (deleted reference)` race condition with pending layout pass |
| `_root.Dispose()` via `View.PostDelayed(200ms)` | Sometimes never executes (runnable cancelled when view detached from window) |
| `_root.Dispose()` via `Handler(Looper.MainLooper).PostDelayed(200ms)` | Confirmed runs (verified via counter) — but **zombie count unchanged** |
| `GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();` after disconnect | No effect |

The fact that `Dispose()` runs successfully but the JNI Global Ref persists suggests **`Mono.Android.ManagedValueManager` (or similar internal cache) holds a strong reference to the Mono peer outside the public `Dispose()` contract**.

## Environment

- **.NET version**: 10.0
- **Microsoft.Maui.Controls**: 10.0.11
- **TargetFramework**: `net10.0-android`
- **Android API**: 34 (emulator + physical Pixel 6 Pro)
- **SupportedOSPlatformVersion**: 24.0

## Heap dump

`heap.hprof` is attached (51 MB). Filter MAT histogram for `crc6431bef388e7f7f9bd.CustomLinearLayout` to see the leaked custom platform view; right-click → Path to GC Roots → exclude weak/soft/phantom shows `JNI Global` as the only GC root.

## Impact

- Memory grows ~1.5 MB per page open/close cycle in our app.
- After ~100 cycles the app reaches risky memory pressure on low-end devices.
- Occasional `JNI ERROR (deleted reference)` crashes occur during teardown when the JNI table is heavily fragmented.

## How to capture the diagnostic files (for reproduction)

### `memory_watch.txt` — per-second `dumpsys meminfo`

Run this PowerShell loop in parallel with the app:

```powershell
$package = "com.package.xyz" # replace with your package
$out = "$env:USERPROFILE\Desktop\memory_watch.txt"
Remove-Item $out -ErrorAction SilentlyContinue

while ($true) {
"====================" | Tee-Object $out -Append
Get-Date | Tee-Object $out -Append

adb shell dumpsys meminfo $package |
Select-String "TOTAL PSS|TOTAL RSS|Native Heap|Dalvik Heap|Java Heap|Graphics|Views|Activities|AppContexts|Objects" |
ForEach-Object { $_.Line } |
Tee-Object $out -Append

Start-Sleep -Seconds 1
}
```

Stop the loop (Ctrl+C) once the 10-cycle reproduction is complete.

### `heap.hprof` — Java heap dump after Force ART GC

```powershell
$package = "com.package.xyz" # replace with your package

# Step 1: send the app to background so we can request COMPLETE-level trim
adb shell input keyevent KEYCODE_HOME
Start-Sleep -Seconds 2

# Step 2: force a full ART GC (frees everything reachable only via dead refs)
adb shell am send-trim-memory $package COMPLETE
Start-Sleep -Seconds 5

# Step 3: dump the Java heap and pull it locally
adb shell am dumpheap $package /data/local/tmp/heap.hprof
adb pull /data/local/tmp/heap.hprof

# Optional: confirm Views count after Force GC
adb shell dumpsys meminfo $package | Select-String "Views:"
```

The dump captured this way reflects the **post-GC steady state** — anything still in the heap is genuinely retained, not waiting for collection. Open `heap.hprof` in **Eclipse MAT** (older versions may need `hprof-conv heap.hprof heap_std.hprof` from Android SDK platform-tools first).

## Attached diagnostic files

- **[memory_watch.txt](https://github.com/user-attachments/files/27471742/memory_watch.txt)** — output of a script that runs `dumpsys meminfo ` once per second over the whole reproduction window. Each block contains `TOTAL PSS`, `Native Heap`, `Java Heap`, and `Views` count. Shows the linear growth of `Views` (~86 per cycle) and PSS (~1.5 MB per cycle) across 10 open/close cycles, with no recovery after Force GC.

- **[heap.zip](https://github.com/user-attachments/files/27471746/heap.zip)** — zipped Android `heap.hprof` (51 MB → ~12 MB compressed) captured **after** the 10-cycle reproduction + `adb shell am send-trim-memory COMPLETE` (full ART GC). Open with Eclipse MAT (must run `hprof-conv` first if using older MAT versions). Histogram filter `crc6431bef388e7f7f9bd.CustomLinearLayout` shows the 186 leaked custom platform-view instances; right-click → *Merge Shortest Paths to GC Roots → exclude weak/soft/phantom references* reproduces the screenshot above.

Image

**Eclipse MAT — Path to GC Roots for `CustomLinearLayout` instances (excluding weak/soft/phantom refs).**
All 186 zombie instances have `JNI Global` as the GC root, holding `MaterialTextView` and other child views via `mChildren`.

## Minimal reproduction code

The following sanitized excerpts (extracted from our production app) are sufficient to reproduce the leak in a fresh `dotnet new maui` project. Drop these files into the appropriate folders, register the handler in `MauiProgram.cs`, then navigate back-and-forth between two pages.

### 1. `Controls/EnhancedButton.cs` (MAUI cross-platform control)

```csharp
using Microsoft.Maui.Controls;

namespace JniLeakRepro.Controls;

// Custom Button subclass with extra bindable properties (icon image + text + description).
// Concrete properties trimmed for brevity.
public class EnhancedButton : Button
{
public static readonly BindableProperty ImageSourceNameProperty =
BindableProperty.Create(nameof(ImageSourceName), typeof(string), typeof(EnhancedButton), "");

public new string ImageSourceName
{
get => (string)GetValue(ImageSourceNameProperty);
set => SetValue(ImageSourceNameProperty, value);
}
}
```

### 2. `Platforms/Android/CustomLinearLayout.cs` (Java/native subclass)

```csharp
using Android.Content;
using Android.Util;
using Android.Widget;

namespace JniLeakRepro.Platforms.Android;

// Java type generated as ACW: crc.CustomLinearLayout
// Hash prefix is what shows up in Eclipse MAT histogram.
public class CustomLinearLayout : LinearLayout
{
public CustomLinearLayout(Context? context) : base(context) { }
public CustomLinearLayout(Context? context, IAttributeSet attrs) : base(context, attrs) { }
}
```

### 3. `Platforms/Android/Resources/layout/EnhancedButton.axml`

```xml



```

### 4. `Platforms/Android/EnhancedButtonHandler.cs` (the handler that leaks)

```csharp
using Android.Content;
using Android.Views;
using Android.Widget;
using JniLeakRepro.Controls;
using JniLeakRepro.Platforms.Android;
using Microsoft.Maui.Handlers;

namespace JniLeakRepro.Platforms.Android;

public class EnhancedButtonHandler : ViewHandler
{
private ImageView? _image;
private TextView? _text;
private TextView? _description;
private CustomLinearLayout? _root;

public static readonly IPropertyMapper Mapper
= new PropertyMapper(ViewMapper)
{
[nameof(EnhancedButton.Text)] = (h, b) => h.ApplyText(),
};

public EnhancedButtonHandler() : base(Mapper) { }

protected override CustomLinearLayout CreatePlatformView()
{
var inflater = Context.GetSystemService(Context.LayoutInflaterService) as LayoutInflater
?? throw new InvalidOperationException();
_root = (CustomLinearLayout)inflater.Inflate(Resource.Layout.EnhancedButton, null)!;
return _root;
}

protected override void ConnectHandler(CustomLinearLayout platformView)
{
base.ConnectHandler(platformView);
// Obtain inner views via GetChildAt - these are auto-wrapped by Mono peers.
_image = _root!.GetChildAt(0) as ImageView;
_text = _root!.GetChildAt(1) as TextView;
_description = _root!.GetChildAt(2) as TextView;
}

protected override void DisconnectHandler(CustomLinearLayout platformView)
{
base.DisconnectHandler(platformView);
// Per MAUI docs base.DisconnectHandler nulls handler.PlatformView,
// but Mono peer for `_root` keeps its JNI Global Ref.
// Adding `_root.Dispose()` here causes JNI race-crash with pending layout pass.
}

void ApplyText()
{
if (_text != null) _text.Text = VirtualView?.Text ?? "";
}
}
```

### 5. `MauiProgram.cs` (handler registration)

```csharp
builder.ConfigureMauiHandlers(handlers =>
{
handlers.AddHandler();
});
```

### 6. `MainPage.xaml` (host page with cyclic navigation)

```xml




```

### 7. `MainPage.xaml.cs` (reproduces the leak)

```csharp
using Microsoft.Maui.Platform; // for DisconnectHandlers()
using JniLeakRepro.Controls;

namespace JniLeakRepro;

public partial class MainPage : ContentPage
{
public MainPage() => InitializeComponent();

async void OnRunCycles(object sender, EventArgs e)
{
for (int cycle = 0; cycle < 10; cycle++)
{
// Add 20 EnhancedButtons (simulating opening a "doklad" page)
for (int i = 0; i < 20; i++)
{
var btn = new EnhancedButton { Text = $"Btn {i}" };
Grid.SetRow(btn, i / 5);
Grid.SetColumn(btn, i % 5);
ButtonHost.Children.Add(btn);
}
await Task.Delay(300);

// Disconnect all handlers + clear (simulating closing the "doklad")
foreach (var child in ButtonHost.Children.OfType().ToList())
child.DisconnectHandlers();
ButtonHost.Children.Clear();

GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();

await Task.Delay(300);
}
}
}
```

After running `OnRunCycles` once and then forcing ART full GC + heap dump (commands above), Eclipse MAT will show ~180+ leaked `crc.CustomLinearLayout` instances all rooted at `JNI Global`, with no path back to managed application code.

## Suggested fix direction

Either:
1. `Java.Lang.Object.Dispose()` should reliably remove the peer from `JniRuntime.JniValueManager.RegisteredInstances` and call `JNIEnv.DeleteGlobalRef`.
2. `MauiContext.Services` (or whichever per-page IServiceProvider holds handler references) should be disposed when the page is destroyed, so its strong references to platform views are released.
3. `ViewHandler.DisconnectHandler()` should also dispose the `_platformView` peer, not only null the field.

### Steps to Reproduce

_No response_

### Link to public reproduction project repository

_No response_

### Version with bug

10.0.20

### Is this a regression from previous behavior?

Not sure, did not test other versions

### Last version that worked well

Unknown/Other

### Affected platforms

Android

### Affected platform versions

All Android versions, that I tried

### Did you find any workaround?

_No response_

### Relevant log output

```shell

```

Contributor guide

No contributing guide indexed for this repository

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.