[.NET 10 / .NET 11 RC2] Native JNI control blocks leak after peer finalization
メンテナーはふだん 1 日以内に返信
評価
- 難易度
- 4/5
- 見積もり時間
- 3〜5日
- 初心者へのやさしさ
- 35/100
調査の方向性
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.
索引モデルが issue の本文から書いたものです。
説明
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
-
Create a plain
net10.0-androidornet11.0-androidapp using the corresponding versions above. No MAUI or additional application dependencies are needed. For RC2, use the default CoreCLR runtime and setSupportedOSPlatformVersionto at least24. -
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> -
Add the helper below. Run
await Task.Run (() => PeerCleanupRepro.RunAsync (dispose: false));from the activity, keeping the app visible and the device awake. -
Restart the process and repeat with
dispose: true. -
Compare
Android.OS.Debug.NativeHeapAllocatedSizeat 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.
- 主要言語
- C#
- スター
- 2.1k
- フォーク
- 579
- 平均マージ
- 1日 16時間
- マージ済み PR(30日)
- 249
環境構築
- Dockerfile・Docker Compose ファイルなし
- プルリクエストのテンプレートあり
- コントリビューションガイドなし
はじめの一歩
- issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
- 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
- リポジトリをフォークし、ブランチを切って変更します。
- issue 番号を参照したプルリクエストを送ります。
dotnet/android のほかの issue
-
Area: App+Library Build
難易度 2/5 1〜3時間 初心者へのやさしさ 72/100
メンテナーはふだん 1 日以内に返信
-
Area: Mono.Android
難易度 2/5 1〜3時間 初心者へのやさしさ 65/100
dotnet/android#9192 · コメント 4 件 · リアクション 1 件 ·
メンテナーはふだん 1 日以内に返信
-
agentic-workflows needs-triage
難易度 4/5 1〜2日 初心者へのやさしさ 25/100
メンテナーはふだん 1 日以内に返信
-
AndroidTypeMapImplementation trimmable removes types that are used in type casts対応中かも @simonrozsival が 2 日前に担当しました。 オープンArea: App Runtime bug trimmable-type-map
dotnet/android#13286 · コメント 2 件 · 担当者 1 名 ·
メンテナーはふだん 1 日以内に返信
-
flaky-ci flaky-tests needs-triage
難易度 4/5 3〜5日 初心者へのやさしさ 35/100
メンテナーはふだん 1 日以内に返信
似ている issue
-
難易度 2/5 1〜3時間 初心者へのやさしさ 78/100
メンテナーはふだん 1 日以内に返信
-
bug 優先: 低
難易度 2/5 1〜3時間 初心者へのやさしさ 76/100
メンテナーはふだん 1 日以内に返信
-
難易度 1/5 1時間未満 初心者へのやさしさ 92/100
-
.NET area: storage bug good first issue
難易度 2/5 1〜3時間 初心者へのやさしさ 78/100
Larson-Logan/medal-sync#61 ·
メンテナーはふだん 1 日以内に返信
-
難易度 1/5 1時間未満 初心者へのやさしさ 88/100
メンテナーはふだん 1 日以内に返信