Skip to content

[Mono.Android] Fix .NET 10 peer control-block lifetime leaks - #13293

Merged
jonathanpeppers merged 2 commits into
release/10.0.1xxfrom
simonrozsival-net-10-peer-cleanup
Oct 28, 2026
Merged

jonathanpeppers merged 2 commits into
release/10.0.1xxfrom
simonrozsival-net-10-peer-cleanup

Conversation

@simonrozsival

Copy link
Copy Markdown
Member

Dependency

Depends on dotnet/java-interop#1502. Do not merge this Android PR before the upstream servicing fix is accepted.

The gitlink points to f79a53b01a33739eb738a2ed951dcc2335bce3f5, published in the canonical dotnet/java-interop repository, not a local or fork-only object. After opening the upstream PR, that exact SHA was successfully fetched into a fresh object database from https://github.com/dotnet/java-interop.git, and the canonical feature branch was verified to advertise the same SHA. If the upstream merge rewrites the commit, update the gitlink to its landed servicing SHA before merging this PR.

Summary

Address the .NET 10 portion of #13290, based on release/10.0.1xx at 49516ae50dffb0421b7faf5f60f9df9cc220743d.

  • Update the Java.Interop submodule from 8e8291c9ef45b4bbc28b09456dc5888eb80af77b to the canonical upstream servicing fix.
  • Release still-invalid native control blocks after Dispose(false), including exception paths, while preserving valid JNI references reconstructed by the callback.
  • Route unregistered DisposeUnlessReferenced() through the normal disposed-state/Dispose(true) path, preserving valid-reference callback entry, JNI deletion, and idempotency.
  • Add 36 on-device regression cases for both JavaObject and JavaException: real natural finalization, invalid/absent blocks, throwing constructors and callbacks, callback reference reconstruction, Mono live-reference re-registration, registered/unregistered peers, aliases, retry after a throwing disposal callback, explicit/idempotent disposal, and JNI global-reference deletion.

The production changes are in the upstream Java.Interop PR; this PR carries its pin and the Android regression fixture. The existing finalization order and bridge/reference-tracking teardown order are unchanged. This is not the unsafe two-call finalizer reorder.

Related main-only coverage is in #13292, where the production corrections already existed. This PR does not include the deferred release/11.0.1xx backport.

Validation

Built the changed in-tree SDK using the repository preparation/bootstrap/PrepareJavaInterop workflow and:

make leeroy
./dotnet-local.sh build tests/Mono.Android-Tests/Mono.Android-Tests/Mono.Android.NET-Tests.csproj \
  -t:SignAndroidPackage -p:RuntimeIdentifier=android-arm64 -p:UseMonoRuntime=true \
  -p:AndroidPackageFormat=apk -p:EmbedAssembliesIntoApk=true

make leeroy completed native runtime builds, API 36/36.1 bindings, and ConfigureLocalWorkload. All builds completed with zero errors; the existing instrumentation project emits platform-analysis and other warnings.

On an owned Android 15 ARM64 emulator, repository RunTestApks instrumentation targets passed 32/32 focused lifecycle cases selected with IncludeCategories=PeerControlBlock and ExcludeCategories=JniReferenceLeak, then 4/4 separately selected JniReferenceLeak cases. Actual NUnit XML case identities/counts were checked. These runs used the source SDK's Mono 10.0.11 runtime.

Rebuilt the public Rect reproducer through ./dotnet-local.sh with the changed in-tree framework and RuntimeFrameworkVersion="10.0.13", and verified the loaded framework identities. No stock framework DLLs were rewritten. Between batches 5 and 20, representing 75,000 additional wrappers:

Mode Native allocated delta JNI global-reference delta
Natural finalization -208 bytes 0
Unregister, then DisposeUnlessReferenced() -400 bytes 0
Explicit disposal +8,544 bytes 0

Median adjacent-batch native growth was 0 bytes per wrapper in all modes. Whole-process allocation counters include background noise; the pointer-level regression assertions independently verify block release.

An additional run intended to select existing peer fixtures ignored the legacy suite selector and ran 945 tests: 934 passed, 5 ignored, and 6 HTTP redirect/certificate tests returned HTTP 403 errors. Those HTTP errors were not baselined and are not claimed to be pre-existing. All 34 cases in the intended existing JavaObjectTest, JavaExceptionTests, and AndroidValueManagerContractTests fixtures passed.

Context: #13290
Context: dotnet/java-interop#1502
Changes: dotnet/java-interop@8e8291c...f79a53b

Update the Java.Interop submodule to the canonical servicing PR commit, which frees still-invalid control blocks after finalization callbacks and routes unregistered disposal through the normal disposed-state/callback path. Preserve callback reference reconstruction, exception cleanup, and Mono bridge ordering rather than swapping Finalized and reference clearing.

Add 36 on-device regression cases for both JavaObject and JavaException, covering natural finalization, invalid and absent blocks, throwing constructors/callbacks, reference reconstruction, registered and unregistered disposal, aliases, idempotency, and JNI reference deletion. Preserve Mono's re-registration of peers whose Java references are still valid.

The in-tree SDK build and all 36 focused cases passed. The source-built public Rect reproducer on Mono 10.0.13 remained flat over 75,000 additional wrappers: native deltas were -208 bytes for natural finalization and -400 bytes for unregistered disposal, with no JNI global-reference growth.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3ac73074-6c43-4940-8cde-8925203f171d

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The fix depends on the still-open dotnet/java-interop#1502, so a maintainer must verify it lands first and that the Java.Interop pin matches the landed servicing commit.

No open issues 🎉

What changed in this PR

Adds Android regression coverage for the .NET 10 peer control-block lifetime fix in upstream Java.Interop, addressing #13290. The production fix is supplied through the submodule dependency; this PR must wait for upstream PR dotnet/java-interop#1502 to be accepted.

Changes:

  • Adds 36 on-device cases covering JavaObject and JavaException finalization, disposal, exception paths, and callback reference reconstruction.
  • Verifies registered-peer and alias preservation, disposal retries and idempotency, native control-block release, and JNI global-reference deletion.
File Description
tests/​Mono.Android-Tests/​Java.Interop-Tests/​Java.Interop/​JavaPeerControlBlockTests.cs Adds peer-lifetime regression tests and observation helpers.

Review effort: Balanced

Update Java.Interop to the release/10.0.1xx tip after dotnet/java-interop#1502
was merged. The landed commit has the same source tree as the previously
pinned PR commit.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@jonathanpeppers

Copy link
Copy Markdown
Member

@dalexsoto review

@jonathanpeppers

Copy link
Copy Markdown
Member

/review

@github-actions

github-actions Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

✅ Android PR Reviewer completed successfully!

Generated by Android PR Reviewer for #13293

@jonathanpeppers

Copy link
Copy Markdown
Member

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
1 pipeline(s) were filtered out due to trigger conditions.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Needs Changes — CI validation pending

Findings: 0 errors, 0 warnings, 1 non-blocking suggestion posted inline. No production correctness defect found in the reviewed changes.

The pinned Java.Interop change releases invalid control blocks in finally without moving the finalization callback ahead of reference-tracking teardown, and preserves references reconstructed by the callback. Routing unregistered disposal through the normal disposal path also preserves valid-reference callback entry and JNI-reference deletion. The 36-case fixture covers both peer types, callback exceptions, reconstruction, aliases, idempotency, and actual finalization. The upstream dependency, dotnet/java-interop#1502, is merged, and the gitlink points to its landed commit 567e6b66cd5cef134e5f3607887d950a4c2b74b0.

CI: On head 9ef07e6ff1fe2bd8409384dbb677cf4a9dea738d, only license/cla is reported and passing; there are no dotnet-android checks or Azure DevOps build links, and combined status is pending. Build/device-test validation therefore cannot yet be assessed as green. The Mono ARM64 results in the PR description are author-reported; I did not independently rerun them.

Generated by Android PR Reviewer for #13293 · copilot · gpt61 · 58.9 AIC · ⌖ 7.95 AIC · ⊞ 19.3K
Comment /review to run again

var observation = new FinalizationObservation ();
FinalizerHelpers.PerformNoPinAction (() => CreateCollectiblePeer (exception, invalid, throwingConstructor, observation));
try {
var stopwatch = Stopwatch.StartNew ();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 💡 Testing — Consider transferring worker-thread exceptions back to the NUnit thread here. FinalizerHelpers.PerformNoPinAction starts a raw Thread and only joins it; it does not propagate exceptions. CreateCollectiblePeer now executes Assert.Throws<InvalidOperationException> on that worker, so a failed assertion (for example, if construction throws a different exception) escapes the thread and can terminate the instrumentation process instead of producing an ordinary failed test case. Keep the no-pin worker, but capture failures inside its action and rethrow/assert on the test thread after the join.

{Rule: Report test failures on the test runner thread}

@jonathanpeppers

Copy link
Copy Markdown
Member

Verified this PR with the actual MaintainerHandoff_AndroidPeerCleanup_20261023.zip sample. The unfixed .NET 10 source reproduces the native memory leak, and this fix removes the continuing increase for this sample.

Source and test settings

Android revision Java.Interop revision
Unfixed 49516ae50dffb0421b7faf5f60f9df9cc220743d 8e8291c9ef45b4bbc28b09456dc5888eb80af77b
Fixed 9ef07e6ff1fe2bd8409384dbb677cf4a9dea738d 567e6b66cd5cef134e5f3607887d950a4c2b74b0

Both source SDK/framework builds succeeded. The sample used the source-built local workload through dotnet-local.cmd, not the handoff's prebuilt APKs or rewritten framework DLLs.

The comparison used the same Android 10/API 29 x86_64 emulator, Mono/.NET 10.0.11, private .NET SDK 10.0.400, and JDK 21. The SDK/framework build configuration was Debug; the sample was Release, with trimming, AOT, the interpreter, and marshal methods disabled.

The sample's SDK pin was changed from 10.0.402 to the branch's prepared 10.0.400 SDK. RuntimeIdentifier was overridden to android-x64. The collector's ADB and APK paths were adjusted for the emulator and a private ADB server. The allocation workload and collection/finalization steps were unchanged.

Measurements

One finalization run and one explicit Dispose control were completed for each source version, using fresh app processes. Each run performed 2,000 warm-up allocations and 20 batches of 5,000 Android.Graphics.Rect wrappers. All measured checkpoints and completion records were present. Framework instrumentation was disabled throughout.

All changes below are in bytes. Batch 0 is after warm-up; batches 5-20 cover 75,000 new wrappers.

Version / case Native change, 0-20 Native change, 5-20 Native change, 10-20 Managed live change, 5-20 Java live change, 5-20
Unfixed, finalization +2,406,576 +2,257,712 +1,456,464 +232 +784
Unfixed, explicit Dispose -326,632 +104,160 +544 0 +832
Fixed, finalization -814,752 +93,952 +832 0 +808
Fixed, explicit Dispose -820,848 +14,296 -88,968 +264 +832

The unfixed finalization case added about 160,000 native bytes per 5,000-wrapper batch. The median batch increase over batches 6-20 was 31.9808 bytes per wrapper, while managed and Java live memory stayed almost unchanged. Explicit disposal did not show that continuing increase.

The fixed finalization result includes a one-time 93,200-byte increase at batch 6, followed by only 752 bytes of total change from batch 6 to batch 20. Its median batch increase was 0.0064 bytes per wrapper. The later 10-20 window changed by only 832 bytes across 50,000 new wrappers. This separates the remaining background changes from the previous increase with each allocation.

Framework identity checks and limits

The source pack DLL bytes matched the decoded framework DLL payloads in each signed APK. Their module identifiers matched the APK's native type maps. Runtime START records reported Java.Interop 8e8291c9 for the unfixed run and 567e6b66 for the fixed run. The sample/helper DLL hashes and all native runtime library hashes, including libmonosgen-2.0.so and libmonodroid.so, were identical between versions.

One version-label detail: the initial preparation generated Mono.Android's informational version at PR HEAD. That generated label remained during the incremental baseline build, so both START records name 9ef07e6 for Mono.Android. Android production sources under src and build-tools are identical between these revisions. The exact baseline checkout, distinct Java.Interop runtime revision, matching source-pack/APK hashes, and absence of FreeIfInvalid metadata in the baseline independently identify the unfixed implementation.

This verifies the reported defect with this sample on x86_64. ARM64 hardware was not tested, and this does not establish that all native memory growth in a larger app is fixed. Exact commands, build logs/binlogs, signed APKs, DLL hashes, and all four measurement captures were retained locally.

@dalexsoto

Copy link
Copy Markdown
Member

@jonathanpeppers Not reviewing this request in this iteration: The read-only review worker stopped with an authorization error: "401 You do not have access to the organization tied to the API key." It did not produce a complete review. GitHub access is working, and the request and partial source evidence are preserved; I am withholding a formal decision rather than treating the failed attempt as a clean review.

@jonathanpeppers

Copy link
Copy Markdown
Member

@dalexsoto stamp

@dalexsoto dalexsoto left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stamping as requested by @jonathanpeppers; no code review performed.

@jonathanpeppers

Copy link
Copy Markdown
Member

Checked the failed tasks and test-result XML files in build 15844117. None of the failures appear related to this PR.

Failure Evidence
HTTP redirect tests Requests return 403 Forbidden. These tests use external HTTP endpoints, including httpbin.org; this PR does not change HTTP handling.
Interpreter authentication test (RequestWithoutCredentialsFails) The local test server cannot start: “Address already in use.”
Windows debugging test (CustomApplicationRunsWithDebuggerAndBreaks(True,True,"aab")) The debugger connection took 3.09 seconds, exceeding the test's 1-second limit. This appears to be a timing failure.
NativeAOT custom-view reference leak (InflateCustomView_ShouldNotLeakGlobalRefs) Confirmed pre-existing. The unfixed release branch's build 15793183, at 49516ae50dffb0421b7faf5f60f9df9cc220743d, reports the same failure with exactly 297 extra global references (Before=153, After=450).

The new JavaPeerControlBlockTests passed in all eight configurations that ran them: 286 successful test-case executions. Debug, Release, interpreter, NoAab, AOT/LLVM, NoAot, and partial trimming each passed 36 cases; CoreCLR passed 34 cases. No failure was reported in the new fixture.

The NativeAOT failure is directly confirmed on the unfixed baseline. The network, local-port, and debugger timing failures appear unrelated based on their errors; they were not reproduced separately in this investigation.

@jonathanpeppers
jonathanpeppers merged commit 5afc310 into release/10.0.1xx Oct 28, 2026
1 of 2 checks passed
@jonathanpeppers
jonathanpeppers deleted the simonrozsival-net-10-peer-cleanup branch October 28, 2026 16:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants