Skip to content

[typemap] Fix trimmable peer creation race - #12650

Merged
simonrozsival merged 7 commits into
mainfrom
simonrozsival-fix-coreclr-object-array-marshaling
Sep 3, 2026
Merged

[typemap] Fix trimmable peer creation race#12650
simonrozsival merged 7 commits into
mainfrom
simonrozsival-fix-coreclr-object-array-marshaling

Conversation

@simonrozsival

@simonrozsivalsimonrozsival commented Sep 2, 2026

Copy link
Copy Markdown
Member

Context

Fixes#10973.

GetObjectArray was exposing a peer-registry bug rather than an array-conversion bug: a caller could cache one managed wrapper while the registry retained a different wrapper for the same Java object.

Root cause

Trimmable type-map proxies registered their implicit peer from the activation constructor (ConstructPeerCore) beforeTrimmableTypeMap marked it Replaceable. AddPeer() deliberately lets a non-replaceable peer evict an existing replaceable one, so every newly created intermediary evicted the peer an earlier caller was already holding — leaving that caller with a wrapper the runtime no longer knew about.

Java.Interop-style generated proxies also hardcoded JniObjectReferenceOptions.Copy, discarding JniHandleOwnership.DoNotRegister, so they always self-registered.

Fix

  • Activate implicit peers with DoNotTransfer | DoNotRegister, and register them only afterMarkCreatedPeer() has applied Replaceable/Activatable.
  • Propagate DoNotRegister through generated Java.Interop-style activation IL.
  • Add regression tests plus a generated-IL assertion.

The ownership → options mapping lives in JNIEnv.ToJniObjectReferenceOptions(), an aggressively inlineable helper, so it reads as a conditional between two named enum members rather than bit arithmetic emitted inline. The generated stub simply loads ownership and calls it.

Deliberately unchanged

JniRuntime.JniValueManager is untouched. An earlier revision canonicalized peers inside the shared GetPeer(), but that base implementation is used by every value manager — and its only production caller, Java.Lang.Object.GetObject(), is on the hot path for Mono, llvm-ir CoreCLR, and NativeAOT too. That changed peer creation for three runtimes which never had this bug.

Substituting the registered peer also conflicts with the documented contract: JniRuntimeJniValueManagerContract.CreatePeer_ReplaceableDoesNotReplace requires CreatePeer() to return a new peer even when a compatible one is already registered.

Neither was necessary. A caller that loses a creation race simply keeps its own peer as an unregistered alias; the fix only stops that peer from evicting the registered one.

Validation

Verified the regression tests genuinely fail without the fix: reverting only ImplicitPeerOwnership to DoNotTransfer fails all three (AfterStalePeerLookup, AfterConcurrentPeerLookup, DuringConcurrentPeerLookup); restoring it passes all three.

  • CoreCLRTrimmable full device suite: 704 passed, 12 skipped
  • 20-thread peer-registration torture test: passed for 10m 11s, alternating GetPeer() and GetObjectArray() against a fresh Java object each iteration, asserting on every iteration that all 20 workers raced through activation, that exactly one of their peers is the registered peer, and that GetObjectArray() agrees with the registry
  • Trimmable type-map generator tests: 778 passed
  • Java.Interop tests: 707 passed, 6 skipped
  • NativeAOT JNI object-array tests: 4 passed
  • Regular CoreCLR control: 1 passed, 3 trimmable-only tests skipped

Defer implicit peer registration until replaceable state is assigned, preserve DoNotRegister through generated Java.Interop activation, and return the canonical registered peer from racing lookup and array-conversion paths.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
CopilotAI lite review requested due to automatic review settings September 2, 2026 23:44

CopilotAI 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.

Copilot review overview

🔵 Needs a closer look

It changes core peer-registration/canonicalization behavior across runtime + generator code paths, which is correctness-critical and warrants final human validation.

Review tier: Lite
Findings: 1 Medium severity · 1 Low severity

New issues introduced by this change (2)
SeverityFinding
Medium severitytests/​Mono.Android-Tests/​Mono.Android-Tests/​Java.Interop/​TrimmableTypeMapRuntimeCoverageTests.cs — 💡 suggestion ConcurrencyActivationBarrier is written on one thread and read on other…
Low severitysrc/​Microsoft.Android.Sdk.TrimmableTypeMap/​Generator/​TypeMapAssemblyEmitter.cs — 💡 suggestion MaintainabilityEmitJniObjectReferenceOptions() currently hardcodes the…
What changed in this PR

Fixes a CoreCLR trimmable typemap peer-registry race where concurrent peer creation could allow a rejected/unregistered managed wrapper to escape, by ensuring DoNotRegister is propagated through activation and by canonicalizing return values to the registered “winner” where appropriate.

Changes:

  • Adjust trimmable typemap activation to defer peer registration until after Replaceable/Activatable state is assigned, and optionally return the already-registered compatible peer.
  • Propagate JniHandleOwnership.DoNotRegister into generated Java.Interop-style activation IL (and extend IL maxstack tracking for new opcodes).
  • Add regression tests for stale lookup and concurrent GetPeer/GetObjectArray scenarios plus an emitted-IL assertion.
FileDescription
tests/​Mono.Android-Tests/​Mono.Android-Tests/​Java.Interop/​TrimmableTypeMapRuntimeCoverageTests.csExtends runtime coverage assertions and adds synchronization hooks to validate activation/registration behavior.
tests/​Mono.Android-Tests/​Mono.Android-Tests/​Android.Runtime/​JnienvArrayMarshaling.csAdds deterministic regression tests for stale and concurrent object-array conversions under trimmable typemap.
tests/​Microsoft.Android.Sdk.TrimmableTypeMap.Tests/​Generator/​TypeMapAssemblyGeneratorTests.csVerifies generated activation IL propagates DoNotRegister into JniObjectReferenceOptions.
src/​Mono.Android/​Microsoft.Android.Runtime/​TrimmableTypeMap.csRoutes activation through a unified registration path and introduces DoNotRegister implicit-ownership for activation.
src/​Microsoft.Android.Sdk.TrimmableTypeMap/​Generator/​TypeMapAssemblyEmitter.csUpdates emitted Java.Interop-style activation IL to incorporate DoNotRegister.
src/​Microsoft.Android.Sdk.TrimmableTypeMap/​Generator/​PEAssemblyBuilder.csExtends maxstack tracking to support additional IL opcodes used by the new emission sequence.
external/​Java.Interop/​src/​Java.Interop/​Java.Interop/​JniRuntime.JniValueManager.csCanonicalizes GetPeer() results to the registered peer when a concurrent registration wins.

@simonrozsival
simonrozsival marked this pull request as draft September 3, 2026 05:59
@simonrozsivalsimonrozsival changed the title Fix trimmable peer creation race[typemap] Fix trimmable peer creation raceSep 3, 2026
Name the generated ownership bit constants and make the activation barrier reference volatile for explicit cross-thread visibility.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

@simonrozsivalsimonrozsival left a comment

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

⚠️ Needs Changes

Correctness-focused review. The root-cause analysis holds up: I traced the pre-fix flow and confirmed the implicit peer registered from ConstructPeerCorebeforeMarkCreatedPeer set Replaceable, so AddPeer's target.Replaceable && !value.Replaceable branch let the intermediary evict an already-registered peer — exactly the identity divergence in #10973. DoNotRegister + register-after-marking is the right fix, and the emitted bit math checks out: DoNotRegister (0x10) >> 2 lands exactly on the DoNotRegisterTarget bit (1 << 2), so Copy | … yields CopyAndDoNotRegister (5).

Issues: 1 error · 2 warnings · 1 suggestion

The one that matters: the canonicalization lives in the shared JniRuntime.JniValueManager.GetPeer, not in the trimmable value manager. GetPeer's only production caller is Java.Lang.Object.GetObject(), which is on the hot path for every runtime — so Mono, llvm-ir CoreCLR, and NativeAOT all pick up a new AddPeer call plus a dispose-and-substitute path for a bug none of them have. None of the added regression tests cover those runtimes, and the PR notes Mono couldn't be validated locally. Putting the canonicalization behind a virtual creation hook that only TrimmableTypeMapValueManager overrides keeps base-class behavior identical.

CI:MAUI Tests / MAUI Integration failed with MAUI R2R Helix submission failed for CoreCLR/android-arm64/Full R2R with exit code 1 — a Helix submission failure, so almost certainly infrastructure rather than these changes, but it needs to be green before this leaves draft. Most other legs are still pending.

Nice work: the three regression tests are genuinely deterministic rather than timing-dependent — putting the Barrier inside the activation constructor forces both callers past the registry miss before either can register, so the race can't be scheduled away. And asserting that the losing wrapper is disposed (not merely dropped) is the right check, since abandoning it would leak a global reference.

Comment threadsrc/Mono.Android/Microsoft.Android.Runtime/TrimmableTypeMap.cs Outdated
simonrozsivaland others added 5 commits September 3, 2026 10:26
Revert the JniRuntime.JniValueManager.GetPeer() canonicalization added in the
previous commit and drop the peer-substitution logic from
TrimmableTypeMap.RegisterCreatedPeer().
GetPeer() is the base implementation shared by every JniValueManager, and its
only production caller is Java.Lang.Object.GetObject(), which is on the hot path
for Mono, llvm-ir CoreCLR and NativeAOT as well. Canonicalizing there changed
peer creation for three runtimes that never had this bug, and none of the added
regression tests cover them.
Substituting the registered peer was also incompatible with the documented
CreatePeer() contract: JniRuntimeJniValueManagerContract.CreatePeer_Replaceable-
DoesNotReplace requires CreatePeer() to return a *new* peer even when a
compatible peer is already registered.
Neither was needed. The bug is that the implicit peer registered itself from
ConstructPeerCore() before MarkCreatedPeer() applied Replaceable, and AddPeer()
lets a non-replaceable peer evict a replaceable one — so each new intermediary
evicted the peer an earlier caller was holding. Activating with DoNotRegister
and registering after marking is sufficient: the loser of a race now keeps its
own peer as an unregistered alias instead of corrupting the registry.
Also address review feedback: make the test peer's ConstructorInvocations
counter atomic (ActivationBarrier guarantees concurrent constructor entry) and
document the cross-assembly enum coupling behind the emitted
`Copy | ((ownership & DoNotRegister) >> 2)`.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The expression `Copy | ((ownership & DoNotRegister) >> 2)` reads as arbitrary
bit twiddling. Spell out what it computes and why: which bit is being carried
between the two enums and why they are two positions apart, why the mask drops
the transfer bits, why Copy is always set, and why arithmetic is preferred over
a branch in emitted IL.
Comment-only; the emitted IL is unchanged.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The generated Java.Interop-style activation stub converted its JniHandleOwnership
argument into JniObjectReferenceOptions by emitting
`Copy | ((ownership & DoNotRegister) >> 2)` inline. That is unreadable at the
call site, and it only worked because JniHandleOwnership.DoNotRegister (0x10) and
the DoNotRegisterTarget bit of JniObjectReferenceOptions (1 << 2) happen to sit
two bit positions apart in two unrelated enums.
Move the mapping into JNIEnv.ToJniObjectReferenceOptions(), an aggressively
inlineable internal helper, where it is a plain conditional between two named
enum members and no longer depends on that accidental alignment. The generated
stub now just loads `ownership` and calls it.
The generator test previously pinned the emitted byte sequence, which asserted
the shape rather than the meaning; it now resolves the call target from metadata
and asserts the stub calls JNIEnv.ToJniObjectReferenceOptions with `ownership`.
Also drop the And/Or/Shr_un stack-tracking cases added for the old sequence,
which no longer has any consumer.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The summaries and inline comments for the Java.Interop-style CreateInstance
emitters still described the options argument as
`Copy | ((ownership & DoNotRegister) >> 2)`, which is no longer what is emitted.
Point them at JNIEnv.ToJniObjectReferenceOptions(ownership) instead.
Comment-only.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The Mac build failed with CS8602 in Generate_JiStyleCtor_PropagatesDoNotRegister:
GetILBytes() returns byte[]?, and the hand-rolled IL scan dereferenced it via
il.Length. It only slipped through locally because CI treats warnings as errors;
the previous revision used AsSpan(), which tolerates null.
Rewrite the assertion to follow the MemberRef-table idiom already used by
Generate_JiStyleCtor_EmitsDeleteRefCall: resolve JNIEnv.ToJniObjectReferenceOptions
from metadata, then look for `ldarg.2; call <that token>`. That removes the null
dereference, the unguarded il[i - 1] read, and the possibility of a stray 0x28
byte inside an operand being decoded as a bogus member-reference token.
Java.Lang.Object.FromJniHandleOwnership already performed exactly this
JniHandleOwnership -> JniObjectReferenceOptions conversion, so the new helper had
duplicated it. Drop it and route SetHandle through
JNIEnv.ToJniObjectReferenceOptions; both produce Copy (1) or
CopyAndDoNotRegister (5), so behavior is unchanged.
Also restore a blank line removed by accident in EmitMemberReferences.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@simonrozsival

Copy link
Copy Markdown
MemberAuthor

/review

@simonrozsivalsimonrozsival added the ready-to-review This PR is ready to review/merge, I think any CI failures are just flaky (ignorable). label Sep 3, 2026
@simonrozsival
simonrozsival marked this pull request as ready for review September 3, 2026 12:40
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Android PR Reviewer completed successfully!

Generated by Android PR Reviewer for #12650

@github-actionsgithub-actionsBot 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

No blocking code defect found; the ownership propagation and delayed peer registration are consistent with the registry semantics. I left one 💡 testing suggestion to make the concurrent regression reproduce the intended eviction ordering deterministically.

Findings: 0 errors · 0 warnings · 1 suggestion

The latest Azure DevOps build (1580767) is still in progress with no failures observed so far, so this cannot be marked LGTM until CI completes.

Generated by Android PR Reviewer for #12650 · gpt56 · 471.6 AIC · ⌖ 19 AIC · ⊞ 25.7K
Comment /review to run again

@simonrozsival
simonrozsival enabled auto-merge (squash) September 3, 2026 13:21
@simonrozsival
simonrozsival merged commit 6b28fae into mainSep 3, 2026
44 checks passed
@simonrozsival
simonrozsival deleted the simonrozsival-fix-coreclr-object-array-marshaling branch September 3, 2026 15:45
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-to-reviewThis PR is ready to review/merge, I think any CI failures are just flaky (ignorable).trimmable-type-map

Projects

None yet

Development

Successfully merging this pull request may close these issues.

JnienvArrayMarshaling.GetObjectArray fails on CoreCLR with emulator 36.4.10

3 participants

@simonrozsival@rolfbjarne