Hacktoberfest 2026: những issue maintainer đã đánh dấu cho tháng Mười, đang mở và phù hợp người mới. Xem issue Hacktoberfest

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

Đang mở
#13,290 0 bình luận 0 reaction 0 người được giao Xem trên GitHub

Maintainer thường phản hồi trong vòng 1 ngày

@simonrozsival đang làm issue này rồi.

Từ ngày 28/10/2026.

  • #13292 của @simonrozsival — đang mở

Đánh giá

Độ khó
4/5
Thời gian dự kiến
3-5 ngày
Mức phù hợp với người mới
35/100
Loại issue
Lỗi
Độ rõ ràng
Đặc tả rõ ràng
Mức độ hoạt động
Sôi nổi
Công nghệ
android, csharp
Lĩnh vực
backend, mobile

Hướng nghiên cứu

Read AndroidRuntime.cs around AndroidValueManager.FinalizePeer and JavaMarshalRegisteredPeers.cs around FinalizePeer, then compare their cleanup order with JavaObject.cs and JavaException.cs. Trace how Finalized() and reference clearing affect control block release, including the unregistered disposal path in JniRuntime.JniValueManager.cs. Done means invalid control blocks are released through finalization and unregistered disposal, while callback reattachment and exception paths remain safe.

Do mô hình lập chỉ mục viết ra từ nội dung của issue.

Mô tả

Area: App Runtime needs-triage
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.

Ngôn ngữ chính
C#
Star
2.1k
Fork
579
Merge trung bình
1 ngày 16 giờ
Pull request đã merge (30 ngày)
253

Chuẩn bị môi trường

  • Không có Dockerfile hay tệp Docker Compose
  • Có mẫu pull request
  • Không có hướng dẫn đóng góp

Bắt đầu từ đâu

  1. Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
  2. Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
  3. Fork repository và làm thay đổi trên một nhánh.
  4. Mở pull request có tham chiếu số hiệu của issue.

Issue khác của dotnet/android

Tất cả issue của dotnet/android

Issue tương tự

Thêm issue về C#

Nhận issue mới trong hộp thư của bạn

Bản tóm tắt ngắn những issue GitHub phù hợp với người mới.