Skip to content

[generator] Eliminate generated ThresholdType and ThresholdClass overrides - #12687

Draft
simonrozsival wants to merge 6 commits into
mainfrom
simonrozsival-eliminate-threshold-overrides
Draft

[generator] Eliminate generated ThresholdType and ThresholdClass overrides#12687
simonrozsival wants to merge 6 commits into
mainfrom
simonrozsival-eliminate-threshold-overrides

Conversation

@simonrozsival

Copy link
Copy Markdown
Member

⚠️ Draft / do not merge

This is an investigation and compatibility prototype for #12679. The design and device coverage are in place, but broader CI and the follow-up method-cache performance investigation are still outstanding.

Why

XAJavaInterop1 currently emits ThresholdType and ThresholdClass overrides into essentially every generated bound type. These overrides duplicate information already represented by JniPeerMembers, add thousands of properties and associated attributes to Mono.Android.dll and its reference assembly, and expand the public API ledger without adding user-facing functionality.

This change removes those generated overrides while preserving virtual/nonvirtual JNI dispatch for bindings compiled before this change.

Fixes #12679

Design

New bindings use metadata dispatch

The XAJavaInterop1 generator now emits plain JniPeerMembers fields:

static readonly JniPeerMembers _members =
    new JniPeerMembers ("android/view/View", typeof (View));

JniPeerMembers.UsesVirtualDispatch() and GetPeerMembers() use the declaring type and receiver peer metadata to choose virtual or nonvirtual dispatch. Newly generated bindings therefore do not need per-type threshold properties.

Old binaries retain legacy dispatch

Previously compiled binding assemblies contain both:

  1. new XAPeerMembers(...) in their generated IL; and
  2. generated ThresholdType / ThresholdClass overrides.

XAPeerMembers remains public and acts as the legacy compatibility implementation. When both the declaring method and receiver use XAPeerMembers, it reads ThresholdType through a normal C# virtual call and applies the original threshold dispatch rule. New generated types use JniPeerMembers, so the runtime can distinguish old and new binding shapes without reflection, configuration, attributes, or assembly-version heuristics.

The protected base properties on Java.Lang.Object and Java.Lang.Throwable remain as binary-compatible virtual slots for old binding overrides. ThresholdClass is no longer consulted by current runtime dispatch.

Mixed-generation inheritance

The important edge case is a new binding deriving from an old binding:

Declaring binding Receiver binding Peer members Dispatch
old old XAPeerMembers legacy threshold semantics
old new derived receiver returns JniPeerMembers metadata semantics; Java override is preserved
new new JniPeerMembers metadata semantics
old managed subclass of old inherited XAPeerMembers legacy nonvirtual base call
old managed subclass of new-derived-from-old inherited new JniPeerMembers metadata nonvirtual base call

A separate old-shape binding assembly and Java hierarchy exercise these combinations on-device.

Other required changes

Three hand-written Mono.Android wrappers still read threshold properties directly and would otherwise dispatch against java/lang/Object after generated overrides disappear. They now use JniPeerMembers invocation APIs:

  • Android.Content.CursorLoader.LoadInBackground()
  • Android.Widget.AbsListView.SetAdapter()
  • Android.Widget.AdapterViewAnimator.Adapter

The existing GC.KeepAlive() protections are retained so borrowed JNI handles remain rooted through native calls.

JavaSideActivation now performs one explicit warm-up instance before measuring GREF lifetime. The removed ThresholdClass access had previously initialized a class reference as an accidental side effect, causing a one-time GREF acquisition inside the measured window.

API and size impact

The API 35–37.2 PublicAPI.Unshipped.txt files record the threshold members as removed. The large ~36,000-line diff is the expected *REMOVED* ledger, not new API surface.

The four hand-written Android.OS.AsyncTask<TParams,TProgress,TResult> entries remain because that type still declares its compatibility overrides. The base virtual pairs on Java.Lang.Object and Java.Lang.Throwable also remain for binary compatibility.

Initial API 37 measurements (before the final peer-members marker refinement):

Artifact / metadata Baseline Prototype Delta
Mono.Android.dll implementation 45,267,456 B 43,788,800 B −1,478,656 B
Mono.Android.dll reference assembly 19,309,056 B 18,309,632 B −999,424 B
Trimmed CoreCLR APK 13,977,298 B 13,973,202 B −4,096 B
Threshold properties 14,229 6 −14,223
Associated custom attributes 32,044 8 −32,036

These numbers should be remeasured before removing the draft / do-not-merge status.

Tests

  • generator-Tests: 490/490
  • Mono.Android.csproj build with API compatibility checks
  • Trimmed Release CoreCLR Xamarin.Android.JcwGen-Tests: 41/41
  • Focused on-device ThresholdDispatch wrapper tests: 3/3
    • exact and managed-subclass AbsListView.SetAdapter
    • exact and managed-subclass AdapterViewAnimator.Adapter
    • exact and managed-subclass CursorLoader.LoadInBackground
  • Old binding exact type, Java-derived type, managed subclass, new-derived-from-old, and managed-new-derived-from-old
  • Full CI
  • Broader host-side Xamarin.Android.Build.Tests
  • Full Mono.Android.NET-Tests completion (a local run reached 254 passes, then the process crashed in unrelated networking test ResponseHeadersReadBodyReadCancellationIsPrompt)
  • Remeasure final artifact sizes

Review notes

  • No runtime option controls legacy dispatch; the binding shape determines it automatically.
  • There is no reflection, UnsafeAccessor, or trimmer-warning suppression in the final detection path.
  • Generator expected-output changes are mechanical: XAPeerMembersJniPeerMembers, plus removal of threshold overrides.
  • A separate experimental branch is evaluating generated per-member JniMethodInfo cache fields versus JniInstanceMethods' string-keyed ConcurrentDictionary; that experiment is intentionally not part of this PR.

  • Useful description of why the change is necessary.
  • Links to issues fixed
  • Unit tests

simonrozsival and others added 6 commits September 4, 2026 13:28
Use JniPeerMembers metadata for new XA bindings while retaining legacy threshold dispatch for types that still declare threshold getters.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Follow-up to 9dcd55f, from a review pass over that change.

* Restore the `GC.KeepAlive (this)` calls that a5779e9
  ("Prevent premature JNI handle collection", fixes #5405) added to
  `AbsListView.SetAdapter` and `AdapterViewAnimator.Adapter`. Rewriting
  those methods to dispatch through `JniPeerMembers` dropped them:
  `InvokeVirtual*Method()` reads `self.PeerReference` and then passes only
  the raw handle to JNI, so the peer is not a live GC root for the duration
  of the native call. `CursorLoader.LoadInBackground` gains the same
  protection and the `try`/`finally` it needs to hold it.

* Compute `XAPeerMembers`' threshold-override detection lazily instead of in
  the constructor. Detection is a name-based `Type.GetMethod()` lookup, and
  running it during type initialization made every bound type an app touches
  pay for reflection on the startup path -- for a result that is `false` for
  every binding generated after 9dcd55f. It is now computed on the first
  dispatch decision, and `UsesLegacyVirtualDispatch()` short-circuits on the
  declaring side so the receiver's lookup is skipped entirely unless legacy
  bindings are actually in play.

  Detection intentionally keys on `ThresholdType` only: every generator path
  that emitted threshold overrides (bound classes, class invokers, interface
  invokers) emitted it, and `ThresholdClass` was never emitted without it.

* Register `Xamarin.Android.LegacyThresholdBinding.csproj` in
  `Xamarin.Android-Tests.slnx`; it previously built only as a transitive
  `ProjectReference` of `Xamarin.Android.JcwGen-Tests`.

* Split the mixed-generation assertions out of `LegacyVirtualMethodBinding`
  into `ModernDerivedFromLegacyBinding` so a failure identifies which
  binding-generation combination broke, and explain what one-time GREFs the
  `JavaSideActivation` warm-up is excluding from its measurement.

Verified: `Mono.Android.csproj` builds clean, JcwGen device suite 41/41 on
emulator-5554.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Remove the reflection-based legacy threshold detection and its IL2070
suppression. Old generated bindings return their JniPeerMembers managed type
from ThresholdType, while new bindings inherit Object's or Throwable's type.
Comparing those values identifies the old shape without reflecting over
non-public methods or relying on a trimmer suppression.

The comparison also handles a new binding derived from an old one: it inherits
the old ThresholdType but replaces JniPeerMembers, so the values differ and
metadata-based dispatch is used. Pure old hierarchies keep matching values and
retain legacy dispatch.

Add focused on-device coverage for the three hand-written wrappers converted
from direct threshold access:

* AbsListView.SetAdapter, including a managed override that proves the base
  call dispatches nonvirtually;
* AdapterViewAnimator.Adapter on exact and managed-derived types;
* CursorLoader.LoadInBackground on exact and managed-derived types, proving a
  base call does not re-enter the managed override.

The wrapper tests share the ThresholdDispatch category for focused execution.

Verified on emulator-5554:
* ThresholdDispatch runtime tests: 3/3
* trimmed Release CoreCLR JcwGen tests: 41/41

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Replace the internal Object.GetThresholdType() and
Throwable.GetThresholdType() forwarding methods with UnsafeAccessor methods
in XAPeerMembers. UnsafeAccessorKind.Method emits callvirt: the accessors bind
to the permanent protected base getters, while old compiled binding overrides
continue to participate in virtual dispatch.

Newly generated bindings no longer override ThresholdType, so they reach the
base getter and are classified for metadata-based dispatch. Old bindings still
override the same base slot and retain legacy dispatch. The protected virtual
properties themselves remain for binary compatibility with those assemblies.

Remove the now-unused internal GetThresholdClass() forwarding methods as well.

Verified on emulator-5554:
* ThresholdDispatch runtime tests: 3/3
* trimmed Release CoreCLR JcwGen tests: 41/41

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Make the legacy/new dispatch distinction explicit in generated code:

* new XAJavaInterop1 bindings instantiate plain JniPeerMembers and therefore
  use its metadata-based dispatch directly;
* already-compiled bindings continue to instantiate XAPeerMembers, which is
  retained as the legacy compatibility implementation.

XAPeerMembers first checks the receiver's peer-members type. For an
XAPeerMembers receiver it then confirms that the normal virtual ThresholdType
getter returns the type represented by those peer members. This second check
keeps hand-written XAPeerMembers users without generated threshold overrides
on metadata dispatch, and handles a new binding deriving from an old one.

ThresholdClass is no longer needed by the compatibility path. The only
remaining forwarding method invokes ThresholdType through normal C# virtual
dispatch; there is no reflection, UnsafeAccessor, or trimmer suppression.

Switch the few hand-written modern peer-member declarations to JniPeerMembers,
refresh generator goldens, and add a regression for an XAPeerMembers-derived
binding without a matching threshold override.

Verified:
* generator-Tests: 490/490
* ThresholdDispatch device tests: 3/3
* trimmed Release CoreCLR JcwGen tests: 42/42
* Mono.Android build and API checks: clean

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Use the simpler XAPeerMembers marker flow requested in review: when the
receiver also uses XAPeerMembers, consult its legacy ThresholdType; otherwise
fall through to JniPeerMembers metadata dispatch.

Keep the existing internal GetThresholdType() name and document its legacy-only
purpose instead of encoding that purpose in a renamed API. Also replace the
empty warm-up using block in JavaSideActivation with an explicit local and
Dispose() call.

Verified: trimmed Release CoreCLR JcwGen tests 41/41.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@simonrozsival simonrozsival added the do-not-merge PR should not be merged. label Sep 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

do-not-merge PR should not be merged.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Investigate eliminating generated ThresholdType and ThresholdClass overrides

1 participant