Repository navigation
Description
Android framework version
net11.0-android (Preview)
Affected platform version
Source audit of dotnet/android main at commit 2a20d9989a (2026-10-06). Current Android initialization uses TrimmableTypeMapValueManager for CoreCLR/NativeAOT. Findings in the shared reflection implementation are listed separately and are not assumed to explain current Android failures.
Description
Progress update — October 21, 2026
The main ownership fixes and leak-measurement improvements from this investigation have merged. Observed CI stability has improved: the previously recurring UTF-8 lookup failures have not recurred in the latest available sample, and the only new class-lookup failure was on an older release branch without the bridge-measurement fix. This does not establish that every possible JNI leak is resolved or identify which change caused each historical failure.
Merged changes
| Finding / change | Merged PR | Merge date (UTC) | Outcome |
|---|---|---|---|
| GC-bridge completion in leak measurements (finding 3) | #13014 — Complete GC bridge accounting in JNI leak measurements | October 6 | Introduces bounded collection/bridge-completion accounting and consistent baseline/post-operation measurements; preserves the actual leak assertions. |
| Exceptional transferred-input cleanup (finding 2) | #13015 — Release transferred JNI inputs on exceptions | October 6 | Releases transferred inputs when lookup/construction throws. Focused original-code regressions demonstrated missing cleanup; this is distinct from attributing successful-operation CI failures. |
| Native peer control-block finalization (finding 1) | #13016 — Release native peer control blocks on finalization | October 9 | Fixes native allocation lifetime while preserving finalization behavior. These allocations are not themselves GREFs and are not measured by the managed GREF counter. |
| Throwable registration contract | #13052 — Honor DoNotRegister for Java.Lang.Throwable handles | October 10 | Fixes premature registration / peer displacement, with failing-before and passing-after registration tests on CoreCLR and NativeAOT. No permanent GREF leak was demonstrated by this discrepancy. |
| Flaky retained-reference positive controls | #13068 — Remove flaky retained-GREF positive controls | October 12 | Removes the two detector self-checks that intermittently observed less than the deliberately retained +100 references. Actual activation/class-lookup leak checks and their thresholds remain. This intentionally loses positive-control coverage; it does not fix the underlying count instability or a runtime leak. |
Finding 4, the reflection activation-failure path, was superseded by removal of the reflection implementation; no separate cleanup-fix PR was published for that path.
CI evidence and stability
The October 11 survey found 3 failures in 125 UTF-8 lookup executions before #13014, with deltas +1, +1, and +2, versus 0 failures in 89 executions after its merge. Builds with any GREF-test failure decreased from 3/125 tested builds (2.4%) to 1/88 (1.1%); the latter was a retained-reference positive-control mismatch, not an operation acquiring an unexpected extra reference. These are calendar-based cohorts, not source-ancestry-controlled experiments.
The follow-up survey covers builds started October 7, 08:23 UTC through October 21, 08:23 UTC. Available evidence comprises 294 completed builds, of which 259 published GREF tests: 534 isolated suite executions and 1,879 individual checks.
| Check | Passed | Failed |
|---|---|---|
| Java-side activation | 262 | 0 |
| UTF-8 class lookup | 272 | 0 |
| String class lookup | 271 | 1 |
| Direct peer-release checks, all four cases | 824 | 0 |
| Retained-reference positive controls, where still present | 247 | 2 |
The three failures in that window were:
- October 7, build 1626120, JCW positive control: expected
Delta=100, observedDelta=99(119 → 218). This is the previously reported failure; its result was preserved in the October 11 survey before Azure expired the build. - October 13, build 1633269, Mono.Android positive control: expected
Delta=100, observedDelta=99(120 → 219). The tested stacked branch included [tests] Complete GC bridge accounting in JNI leak measurements #13014 but did not include the subsequent control removal in [tests] Remove flaky retained-GREF positive controls #13068. - October 14 build date, build 1633921, string class lookup: 18 → 20,
Delta=2. #13128 targetedrelease/11.0.1xx. Inspection of the exact tested commit (a6b3bf72755654b0d758004938f7157f813117c8) confirmed the old measurement helper without [tests] Complete GC bridge accounting in JNI leak measurements #13014. This is not evidence of a regression in the updated helper onmain, nor does the mismatch alone prove a permanent leak. The build succeeded overall despite this non-gating test failure.
Suite executions actually running without the removed controls produced 1,000/1,000 passing checks across 285 suite executions (428 activation/lookup checks and 572 direct-release checks). The absence of the removed controls must not be counted as evidence that their instability was fixed.
Overall, the observed raw affected-build rate remains approximately 1.1–1.2%, rather than showing a resurgence. The stronger signal is that UTF-8 lookup and activation remain stable, while the new lookup failure used the unfixed release-branch helper. The observations support improved test reliability following the changes, but do not isolate each PR's contribution or prove the absence of all leaks.
Coverage limitations: Azure retention has deleted some older records. The latest survey combines 228 live builds with 66 additional builds preserved by the October 11 survey, deduplicated by build/run; it cannot guarantee exhaustive coverage of all deleted records. In-progress builds and unpublished tests are not counted as passing. Test failures are distinct from builds made red by those tests.
Remaining scope
- The test-only native timing helpers' repeated-initialization reference retention remains a follow-up; the Android helper still overwrites
Object_classwith a new global on currentmain. - The broader ownership/retention audit is not complete. Related work remains tracked by #12760.
- Other shared-process count/peer assertions remain within #12031; improved stability of the isolated checks does not resolve all of that issue.
- The original historical intermittent failures were not all individually attributed. A local direct-release mismatch was separately reproduced and attributed to the tested peer's deletion being offset by a concurrent instrumentation
Android.OS.Bundleacquisition; this must not be generalized to every historical failure.
This issue remains open for the residual work above. The original audit below is preserved as historical context; its source-only evidence statements describe the initial October 6 investigation, not the validation subsequently completed.
Original audit — October 6, 2026
Intermittent JNI global-reference leak-check failures remain a concern following recent improvements. This issue tracks source-level ownership findings and measurement concerns, to be investigated and fixed in separate, non-stacked PRs.
Evidence level: This is a source audit, not a runtime reproduction of the intermittent failure. The findings below must not be treated as one established root cause. In particular, native control-block leakage is distinct from GREF leakage, and incomplete bridge processing is a possible measurement problem rather than proof that a failure is harmless.
1. Finalization does not free the native peer control block
JavaMarshalRegisteredPeers.FinalizePeer() clears the peer reference before calling value.Finalized() in both branches:
Finalized() sets the internal Disposed state. However, SetPeerReference(default) frees the NativeMemory.AllocZeroed control block only when that state is already set. The finalization sequence therefore leaves the block allocated, whereas explicit disposal marks the peer disposed before clearing it. The block is 16 bytes on 64-bit targets. An initially invalid peer can also allocate a block while its reference is cleared during finalization.
Classification: Source-backed unmanaged-memory lifetime defect; not itself an unreleased GREF and not visible in the GREF count. Validate actual finalization and callback ordering before changing it; blindly swapping calls could change Dispose(false) behavior.
2. Transferred-reference cleanup is skipped on exceptions
These APIs delete the transferred input handle only after fallible work, without a finally:
- Object.SetHandle(): peer construction/registration.
- Object.GetObject(): peer lookup/activation.
- Throwable.SetHandle(): construction and stack-trace extraction.
- Generated Java.Interop-style proxy activation: constructor invocation before
JNIEnv.DeleteRef().
An exception can strand an input passed with TransferGlobalRef; collection of a partially constructed wrapper releases its own reference, not necessarily that original input. GetPeer() calls CreatePeer() with Copy, so the latter's transfer cleanup does not consume the raw transferred input. Internal typemap creation normally passes DoNotTransfer | DoNotRegister; distinguish that path from explicitly transferring through the proxy API.
Classification: Source-backed exception-path ownership gaps; no evidence yet that successful intermittent activation loops take these paths. Fix ownership boundaries consistently and avoid double deletion through nested layers.
3. Leak checks do not establish completed GC-bridge processing
The baseline uses CollectPeers() while the post-batch sample uses only CollectGarbage(). The value manager's WaitForGCBridgeProcessing() is intentionally a no-op; CollectPeers() only drains already-collected contexts. Native bridge work runs on a separate thread and temporarily converts strong globals to weak globals before promoting survivors.
Classification: Credible collection/measurement timing concern, not a reproduced explanation. Existing JavaMarshalGCBridgeTests already wait for a changed BridgeProcessingGeneration and an expected outcome. Establish completed processing/eventual convergence without weakening the real-leak assertion or adding unbounded waits.
4. Reflection activation failure suppresses cleanup of a potentially constructed peer
ReflectionJniValueManager.TryCreatePeerInstance() suppresses finalization whenever construction does not complete. A derived activation constructor can throw after its base constructor has acquired a GREF, but that failure path does not dispose the peer before suppressing its finalizer.
Classification: Shared reflection/host-JVM exception-path leak candidate. Current Android initialization selects the trimmable value manager, so this is not assumed to cause current Android leak-test failures. Verify with a throwing activation constructor and host-JVM reference accounting.
Additional registration discrepancy: Throwable ignores DoNotRegister
Throwable.SetHandle() always uses Copy rather than preserving JniHandleOwnership.DoNotRegister, unlike Object.SetHandle(). The typemap's RegisterCreatedPeer() relies on withholding registration until the Replaceable state is set. This discrepancy needs Throwable alias/reentrant-activation coverage before its consequences can be attributed to leaks.
Classification: Source-backed registration-contract discrepancy; no demonstrated permanent GREF leak.
Additional test-only native reference retention
Both Android timing helper and Java.Interop timing helper overwrite static Object_class with NewGlobalRef() during foo_init() without releasing a previous value. Repeated initialization would lose the previous reference. These raw JNI calls bypass managed reference accounting and should not be confused with a failing managed GREF-count assertion.
Scope and related work
The first pass covered peer construction/disposal/finalization, registry reconciliation, generated proxy/UCO activation, direct runtime GREF acquisition, class/constructor/remapping caches, array marshaling, proxies, and native hosts. It is not a completed whole-codebase retention audit.
Normal constructor-cache publication disposes losing candidates, and normal existing-reference construction releases the old global when replacing it. Those paths were not identified as another unconditional duplicate-GREF acquisition.
Related context: #10989, #11101, #11201, #10973; recent activation/alias work #12772 and leak-test isolation #12716 and #12709. Historical fixes should not be taken as proof of the current failure mechanism.
Steps to Reproduce
No end-to-end reproduction of the intermittent failure was performed during this audit. Proposed focused regression scenarios:
- Exercise actual finalization of
JavaObjectandJavaExceptionpeers, including initially invalid/partially constructed peers; verify native control-block release independently of GREF deletion. - Warm caches, transfer a fresh global reference into a lookup or activation that throws, and verify the original acquisition is paired with deletion. Cover
Object,Throwable, and supported generated-proxy entry points. - Repeatedly run isolated Java-side activation/class-lookup leak checks while recording strong/weak reference counts, bridge generations, and matched acquisition/deletion logs. Distinguish sustained growth from delayed release and in-progress bridge transitions.
- On the reflection/host-JVM path, activate a peer whose constructor throws after base construction; verify GREF/registry cleanup after collection.
- Add Throwable registration-order coverage and determine whether repeated native timing initialization is reachable before treating the additional observations as runtime leak causes.
Each fix should have a regression that fails before the change, preserve successful-path ownership/activation semantics, and be validated on the relevant runtime. Follow-up PRs should reference this issue without automatically closing the broader investigation while unrelated findings remain unresolved.
Did you find any workaround?
No general workaround established. Explicit disposal normally releases peer references and control blocks, but it does not establish that exceptional transferred-input ownership is correct. Do not relax leak thresholds or assume all failures are GC timing noise.
Relevant log output
N/A — no new runtime reproduction or GREF logs collected during this source audit.
Activity
[tests] Bind leak samples to latest bridge collection
[tests] Drop non-Android transferred-reference fixture
[tests] Complete GC bridge accounting in JNI leak measurements (#13014)
[Mono.Android] Release transferred JNI inputs on exceptions (#13015)
[Java.Interop] Isolate finalization reference-count assertions
[Java.Interop] Align lifetime fixture with current Android tests
[Java.Interop] Release native peer control blocks on finalization (#1…
Honor DoNotRegister for Java.Lang.Throwable handles (#13052)
[tests] Remove flaky retained-GREF positive controls (#13068)