Skip to content

[.NET 10 / .NET 11 RC2] Native JNI control blocks leak after peer finalization #13290

Open

Description

@simonrozsival

Android framework version

net10.0-android with Mono, and net11.0-android with CoreCLR (.NET 11 RC2).

Affected platform version

Component .NET 10 .NET 11 RC2
SDK 10.0.402 11.0.100-rc.2.26504.105
Android pack 36.1.118 37.2.0-rc.2.84
Runtime Mono 10.0.13 CoreCLR 11.0.0-rc.2.26504.105
Mono.Android 36.1.1.117, db080b91dbe7947a69005a203ea98d9bb207ad04 37.2.0.84, 801f9ecbab5118b13d21cb52409e507ad4b2feb7
Java.Interop 10.0.0.0, 8e8291c9ef45b4bbc28b09456dc5888eb80af77b 11.0.0.0, 801f9ecbab5118b13d21cb52409e507ad4b2feb7
Device Android 15 ARM64 emulator Android 15 ARM64 emulator

Source analysis uses release/10.0.1xx at 49516ae50dffb0421b7faf5f60f9df9cc220743d and the installed RC2 framework's release/11.0.1xx-rc2 revision 801f9ecbab5118b13d21cb52409e507ad4b2feb7. Loaded assembly identities and runtime versions were checked in the app. NativeAOT has not been tested; the .NET 11 RC2 SDK rejects Android builds with UseMonoRuntime=true with NETSDK1242.

Description

A plain .NET for Android application that repeatedly creates temporary Android.Graphics.Rect wrappers accumulates native allocated memory after collection and completed finalization. Managed and Java live memory remain approximately stable, and JNI global-reference counts do not increase.

The retained allocation is the JniObjectReferenceControlBlock associated with each wrapper. Its ARM64 payload is 32 bytes in the tested .NET 10 build and 16 bytes in .NET 11 RC2. Separate allocate/free calibrations measured allocator-accounted totals of approximately 48 and 32 bytes per allocation, respectively.

Expected: Once a peer is finalized and its JNI reference is invalid, its native control block is released.

Actual: Finalization completes with an invalid JNI reference and the disposed-state flag set, but the control block remains allocated. On both framework versions this was also observed with naturally finalized Java.Lang.Object and Java.Lang.Throwable subclasses, covering the JavaObject and JavaException implementations.

Cleanup-order defect

In AndroidValueManager.FinalizePeer, the invalid-reference branch executes:

RemovePeer (value, (IntPtr) value.JniIdentityHashCode);
value.SetPeerReference (new JniObjectReference ());
value.Finalized ();

However, JavaObject.SetPeerReference only frees the control block when the supplied reference is invalid and Disposed is already set. Finalized() sets that flag afterward, without retrying the release. JavaException has the same behavior.

In .NET 11 RC2, the default CoreCLR JavaMarshalValueManager delegates to JavaMarshalRegisteredPeers.FinalizePeer. Both branches clear the reference before calling Finalized(), while the RC2 JavaObject implementation still requires the disposed-state flag before freeing the block.

Diagnostic observations after natural finalization, reproduced on both versions:

  • The finalization callback ran, the peer reference was invalid, and the disposed-state flag was set, but the control-block pointer was nonzero.
  • Calling Dispose() afterward did not release it because disposal returns early for an invalid reference.
  • Clearing the reference again released the block.

History points to the native control-block introduction, which moved previously GC-owned peer fields into a separately allocated native block.

Diagnostic A/B and fix constraints

A private .NET 10 diagnostic APK moved Finalized() before SetPeerReference(default). This removed the allocation-proportional native growth. Comparison of the packaged managed method bodies confirmed that only AndroidValueManager.FinalizePeer changed; the Java.Interop binary was identical. The RC2 tests used the installed framework binaries without this experimental change.

The reorder is not a complete fix: a Dispose(false) callback can reconstruct a valid peer reference. The existing sequence preserves that reference, while the reordered unconditional clear erases it. This was reproduced for both object and exception wrappers.

A correction should release still-invalid control blocks at lifecycle completion, including exception paths, while preserving callback semantics and reattached references.

Related unregistered-disposal path

The following sequence also retains the control block:

var rectangle = new Android.Graphics.Rect (1, 2, 3, 4);
rectangle.UnregisterFromRuntime ();
rectangle.DisposeUnlessReferenced ();

DisposePeerUnlessReferenced reaches the private disposal helper without setting Disposed. That helper clears the reference and suppresses finalization, leaving no later cleanup opportunity. This path was reproduced on both framework versions and remains leaky in the .NET 10 finalizer-reorder experiment.

Steps to Reproduce

  1. Create a plain net10.0-android or net11.0-android app using the corresponding versions above. No MAUI or additional application dependencies are needed. For RC2, use the default CoreCLR runtime and set SupportedOSPlatformVersion to at least 24.

  2. For the exact .NET 10 runtime match, add this item to the project. Omit it for .NET 11 RC2:

    <ItemGroup>
      <FrameworkReference Update="Microsoft.NETCore.App" RuntimeFrameworkVersion="10.0.13" />
    </ItemGroup>
    
  3. Add the helper below. Run await Task.Run (() => PeerCleanupRepro.RunAsync (dispose: false)); from the activity, keeping the app visible and the device awake.

  4. Restart the process and repeat with dispose: true.

  5. Compare Android.OS.Debug.NativeHeapAllocatedSize at batches 5 and 20, rather than PSS/RSS. Forced collections here are diagnostic, not a production workaround.

using System;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
using Java.Interop;

static class PeerCleanupRepro {
	public static async Task RunAsync (bool dispose)
	{
		await Task.Delay (2_000);
		AllocateBatch (2_000, dispose);
		await DrainAsync ();
		Sample (0);

		for (int batch = 1; batch <= 20; batch++) {
			AllocateBatch (5_000, dispose);
			await DrainAsync ();
			Sample (batch);
		}
	}

	[MethodImpl (MethodImplOptions.NoInlining)]
	static void AllocateBatch (int count, bool dispose)
	{
		for (int i = 0; i < count; i++) {
			var rectangle = new Android.Graphics.Rect (i, 0, i + 1, 1);
			if (dispose)
				rectangle.Dispose ();
			GC.KeepAlive (rectangle);
		}
	}

	static async Task DrainAsync ()
	{
		for (int pass = 0; pass < 3; pass++) {
			GC.Collect ();
			GC.WaitForPendingFinalizers ();
			JniEnvironment.Runtime.ValueManager.WaitForGCBridgeProcessing ();
			GC.Collect ();
			GC.WaitForPendingFinalizers ();
			await Task.Delay (150);
		}
	}

	static void Sample (int batch)
	{
		Android.Util.Log.Info ("PeerCleanupRepro",
			$"batch={batch} native={Android.OS.Debug.NativeHeapAllocatedSize} " +
			$"managed={GC.GetTotalMemory (false)} " +
			$"grefs={JniEnvironment.Runtime.GlobalReferenceCount}");
	}
}

Did you find any workaround?

Explicit Dispose() avoids the natural-finalization leak for wrappers with known ownership and lifetime. It is not a general workaround for temporary wrappers created inside bindings or framework code.

Relevant log output

Measured native allocated deltas between batches 5 and 20, representing 75,000 additional wrappers in the allocating scenarios:

.NET 10 / Mono

Scenario Stock framework Diagnostic finalizer reorder
Natural finalization +3,452,528 bytes +1,472 bytes
Explicit Dispose() +132,208 bytes +12,880 bytes
No-allocation control, same checkpoints +140,656 bytes +6,704 bytes
Unregister, then DisposeUnlessReferenced() +3,719,360 bytes +3,609,728 bytes

Three stock runs reproduced the finalization growth. In the matched stock finalization run, managed live memory changed by +17,968 bytes, Java live memory by +5,184 bytes, and JNI global-reference counts remained constant. The controls show some native measurement/background-allocation noise, but not the allocation-proportional growth of the two leaking paths.

.NET 11 RC2 / CoreCLR

Two complete runs used the same allocation/drain harness and the installed RC2 framework binaries:

Scenario Run 1 native delta Run 2 native delta
Natural finalization +1,482,320 bytes +1,294,480 bytes
Explicit Dispose() -1,844,096 bytes -1,829,088 bytes
No-allocation control, same checkpoints -249,920 bytes -115,408 bytes
Unregister, then DisposeUnlessReferenced() +568,160 bytes +562,416 bytes

The whole-process native counter has large downward jumps, including in the controls, so these endpoint deltas are not exact leaked-payload totals. The median adjacent-batch increases in the two leaking modes were approximately 32.5-32.6 native bytes per additional wrapper, compared with less than 1 byte per wrapper for explicit disposal.

More decisively, both RC2 runs reproduced the pointer-level diagnostics above: naturally finalized object and exception wrappers retained their 16-byte control blocks; Dispose() did not release them; clearing the reference again did. The unregistered-disposal path also retained its block with the disposed-state flag unset. Java live memory and JNI reference counts were unchanged between batches 5 and 20.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Area: App RuntimeIssues in `libmonodroid.so`.needs-triageIssues that need to be assigned.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions