Skip to content

[Unsafe][Java.Interop] Opt into updated C# memory-safety rules - #13070

Closed
simonrozsival wants to merge 20 commits into
mainfrom
simonrozsival-csharp-memory-safety
Closed

simonrozsival wants to merge 20 commits into
mainfrom
simonrozsival-csharp-memory-safety

Conversation

@simonrozsival

@simonrozsival simonrozsival commented Oct 11, 2026 •

Copy link
Copy Markdown
Member

Related to #11467; this implements the Java.Interop library migration, not the full generated-IL migration tracked by that issue.

Enable project-local LangVersion=preview and append updated-memory-safety-rules in Java.Interop.csproj. Member-level unsafe is now a caller contract, not an unsafe implementation context. Move implementation-only operations into local blocks, preserve genuine caller obligations, remove ineffective type-level modifiers, and classify the nine scalar/IntPtr fields of explicit-layout JniArgumentValue as safe without changing offsets. Update canonical generators, checked-in T4 output, and API baselines. #13085 tracks removing temporary preview overrides.

Replace null-pointer constructor initializers with valid local JNI references while preserving protected sentinel fields for compatibility. Retain direct virtual hash-code calls in JavaObject/JavaException inside local unsafe blocks; no hash-code wrapper or signature validation remains. Scope trimmable-typemap bootstrap registration locally.

Use built-in MemoryExtensions.Replace with the existing thread-local Java-name buffer, cutoff and long-name fallback. Its char implementation uses SIMD when supported; no measured performance improvement is claimed.

UTF-8 contract: Complete C# u8 literals have a guaranteed NUL in backing storage immediately beyond span length (specification). All 18 public UTF-8 lookup/constructor/cache APIs require unsafe, with 20 public/internal declarations documented locally. Preserve zero-copy literal lookup; no copy-and-append fallback, caller-side terminators, or out-of-span validation. String overloads stay safe. Copies and slices do not inherit the literal guarantee; there are no copy-and-terminate regression tests.

Native-memory contracts: Per-operation generator metadata makes public/raw DefineClass, NewDirectByteBuffer, and ReleasePrimitiveArrayCritical require unsafe despite IntPtr parameters, with XML <safety> contracts. Class definition requires nonnegative length and readable initialized bounds at live/stable storage, pinned when managed; JniType.DefineClass(byte[]) supplies its own pinned bounded storage and stays safe. Direct-buffer memory must remain accessible for permitted reads/writes throughout Java alias lifetime. Critical release requires matching active acquisition, valid release mode and JNI critical-region discipline; pointers cannot be reused after final release. GetDirectBufferAddress stays safe: returning an address does not dereference its pointee. JNI invocation behavior is unchanged.

Native callback lifetime and ABI: Both direct delegate-array Types.RegisterNatives overloads require unsafe and document strong rooting for JNI's callback lifetime, including partial/failed registration and in-flight callbacks. GC.KeepAlive protects the call, including exceptional cleanup, not later callbacks. JniType.RegisterNativeMethods snapshots/roots every attempt before JNI publication and registers its runtime owner first; retain prior/failed attempts until successful unregistration. However, rooting does not establish ABI compatibility, so the owned wrapper now also requires unsafe. Its caller must supply compatible JNI parameter/return types, leading JNIEnv/receiver pointers, calling convention and marshaling attributes. The wrapper does not add restrictive ABI validation or change supported delegate shapes. Track owners independently of JNI handle identity, unregister before removing runtime roots, and preserve roots if cleanup fails. Four regressions still cover mutation, repeated attempts, runtime ownership, partial failure and release after unregistration.

Borrowed remapping pointers: Keep ReplacementMethodInfo.ToString() safe by displaying pointer addresses rather than scanning borrowed UTF-8 memory. Managed diagnostic strings are preserved; raw pointer storage/getters remain address-only. Mark the direct protected GetReplacementMethodInfoCore(IntPtr, ...) overload caller-unsafe with lifetime/NUL/readability XML, and propagate that contract through the internal pointer path, audited cached-peer call site and product override. Managed string/span overloads stay safe; valid literal lookup retains the existing managed fallback.

Null environment guards: Reject zero pointers before JNI dereference or thread-local update; regression verifies rejection does not alter the current environment. Preserve VM-only creation's thread-attachment path. Nonzero checks do not prove pointer provenance, readability, affinity or lifetime; broader runtime-creation propagation is separate preparation work.

Primitive-array borrowed-reference contracts: All eight typed and the inherited generic GetElements() methods stay safe. The eight Jni*ArrayElements ref-returning indexers require unsafe, with lifetime XML: retain the array/acquisition, prevent concurrent invalidation, and never use refs after release/disposal. Enforcement applies even when another caller supplies the elements owner. Preserve ref-return ABI and existing checks; managed indexing, Clear, IndexOf and managed-buffer copies stay safe. Add 21 contract/behavior cases. Revert the acquisition-unsafe experiment b4921c35d0 via 00a6533; corrected design is d8b67d8c8a.

Validation

Current-head validation for 5105d3e031e2124c152397c55f8c9f137a5685fe, using repository SDK 12.0.100-alpha.1.26509.101:

  • make leeroy CONFIGURATION=Debug — passed, 0 warnings/errors, including all APIs/packs/workload and dependent raw-hook override propagation.
  • ./dotnet-local.sh build external/Java.Interop/src/Java.Interop/Java.Interop.csproj --no-restore -c Debug -v minimal — passed, 0 warnings/errors including updated API baselines and XML.
  • Actual opted-in consumer projects against both implementation and reference assemblies for all three packed APIs reject the protected raw-pointer call, owned registration call, and the review's incompatible by-ref callback counterexample with exactly three CS9362 diagnostics. Positive scoped calls compile. All six actual product PEs and XML contracts passed. Safe address-only diagnostics execute safely for arbitrary IntPtr values; valid literal lookup preserves managed fallback. No incompatible callback was registered or invoked.
  • ANDROID_SERIAL=emulator-5598 ./dotnet-local.sh build -t:Install -c Debug -p:NuGetAudit=false -p:AndroidDeviceId=emulator-5598 -p:IncludeCategories=JniCallerSafety -p:RuntimeIdentifier=android-arm64 tests/Mono.Android-Tests/Mono.Android-Tests/Mono.Android.NET-Tests.csproj — passed, 0 warnings/errors. From the test project directory, ANDROID_SERIAL=emulator-5598 ../../../dotnet-local.sh test Mono.Android.NET-Tests.csproj --no-build -c Debug -p:NuGetAudit=false -p:AndroidDeviceId=emulator-5598 -p:IncludeCategories=JniCallerSafety -p:RuntimeIdentifier=android-arm64 --device emulator-5598 --report-trx --results-directory ../../../bin/TestDebug/TestResults/alex-second-review-focused — 6 passed, 0 skipped/failed.
  • Rebuilt/reinstalled and reran the same commands with -p:IncludeCategories= and test results directory alex-second-review-full — 974 passed, 7 skipped, 0 failed; 981 total, including retained delegate-rooting and remapping regressions. TRX _Simons-MacBook-Pro_2026-10-13_18_15_04.3537120.trx. Fresh owned API35 CoreCLR Debug/arm64 emulator, exact fixture and AVD/INI cleaned up; shared/physical devices untouched.
  • git diff --check — passed.

Earlier migration/ancestor evidence (historical)

  • UTF-8 consumer: exactly 18 distinct CS9362 safe-call errors; scoped calls and all 18 RequiresUnsafe annotations verified. Direct-buffer/critical-release consumer: two negative CS9362 cases, four public/raw annotations and safe address-only return verified. Primitive-array consumer: exactly 24 CS9362 cases for eight indexer reads/writes/ref borrows, with nine acquisitions callable from safe code; all six product PEs/eight lifetime XML elements verified. Host probes executed no invalid native-memory accesses. The original owned-registration-safe classification/probe is superseded by the current caller-unsafe ABI contract.
  • All five modified T4 outputs were regenerated and compared byte-for-byte — identical. Temporary replacement probe passed 10,260 length/tail cases; no speedup claimed. Earlier explicit caller-NUL and acquisition-unsafe experiments were withdrawn/reverted, not retained.
  • make prepare && make all originally passed preparation/solution but missed additional API assemblies during workload setup. Subsequent make leeroy CONFIGURATION=Debug runs passed with all APIs/workload; no unrelated target repair.
  • Normal merges 63b97b7a2b and 64bd762488 incorporate main 55165f3609 and e03caec363, respectively. Sole manual conflict: canonical JNI invocation T4; retained obsolete NET-guard removal and local unsafe scopes, then regenerated output. Preserved removal of native Java.Interop support library/backends, without history rewrites or stack/base changes.
  • make prepare CONFIGURATION=Debug — passed (12 SourceLink metadata warnings while a merge was uncommitted). make leeroy CONFIGURATION=Debug after commit — passed, 0 warnings/errors. Rebuilt CoreCLR arm64 inventories for all APIs verified free of removed native Java.Interop support archives.
  • DOTNET_ROLL_FORWARD=Major ./dotnet-local.sh test external/Java.Interop/tests/generator-Tests/generator-Tests.csproj --no-restore -c Debug -v minimal — 478 passed, 0 skipped/failed. DOTNET_ROLL_FORWARD=Major ./dotnet-local.sh test external/Java.Interop/tests/Xamarin.SourceWriter-Tests/Xamarin.SourceWriter-Tests.csproj --no-restore -c Debug -v minimal — 11 passed, 0 skipped/failed. Parent suites, not preparation-PR additions.
  • Same full CoreCLR device build/test commands above (with empty IncludeCategories and results directories base-update-full and ancestor-sync-full) — 968 passed, 7 skipped, 0 failed in each run before this follow-up. Latest ancestor TRX _Simons-MacBook-Pro_2026-10-13_14_07_55.7278880.trx. All owned fixtures/AVDs cleaned up. Earlier focused registration and primitive-array runs passed 4/4 and 37/37, respectively; their full suites passed 939 and 960 with 7 skips each. These are historical revisions, not current-head claims.

Release/NativeAOT device lanes and other device ABIs were not rerun for this follow-up. No physical device was used.


Pull Request
title and
description
should follow the
commit-messages.md workflow documentation, and in particular should include:

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

Copilot AI balanced review requested due to automatic review settings October 11, 2026 22:18

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.

🟡 Changes recommended

Several raw-pointer and unterminated-span obligations are incorrectly exposed through safe-callable APIs.

4 open findings
What changed in this PR

Migrates Java.Interop to C#’s updated memory-safety rules while preserving JNI behavior and public API tracking.

Changes:

  • Enables preview memory-safety enforcement and scopes unsafe operations locally.
  • Updates JNI generators, generated sources, constructors, and UTF-8 class lookup.
  • Revises public API baselines for requires-unsafe contracts.
File Description
src/​Mono.Android/​Microsoft.Android.Runtime/​TrimmableTypeMap.cs Scopes native registration operations.
src/​Mono.Android/​Java.Lang/​Object.cs Replaces null-pointer initialization.
external/​Java.Interop/​src/​Java.Interop/​PublicAPI.Unshipped.txt Updates unshipped API contracts.
external/​Java.Interop/​src/​Java.Interop/​PublicAPI.Shipped.txt Records shipped requires-unsafe APIs.
external/​Java.Interop/​src/​Java.Interop/​Java.Interop/​JniType.cs Scopes pointer operations and documents termination.
external/​Java.Interop/​src/​Java.Interop/​Java.Interop/​JniSystem.cs Pins managed argument storage locally.
external/​Java.Interop/​src/​Java.Interop/​Java.Interop/​JniRuntime.JniTypeManager.cs Scopes UTF-8 pointer access.
external/​Java.Interop/​src/​Java.Interop/​Java.Interop/​JniRuntime.cs Updates Java VM invocation handling.
external/​Java.Interop/​src/​Java.Interop/​Java.Interop/​JniPeerMembers.JniStaticMethods.cs Scopes static JNI calls.
external/​Java.Interop/​src/​Java.Interop/​Java.Interop/​JniPeerMembers.JniInstanceMethods.cs Adds safe no-argument hash invocation wrapper.
external/​Java.Interop/​src/​Java.Interop/​Java.Interop/​JniPeerMembers.JniInstanceMethods_Invoke.tt Updates invocation generation.
external/​Java.Interop/​src/​Java.Interop/​Java.Interop/​JniPeerMembers.JniInstanceMethods_Invoke.cs Regenerates instance invocation methods.
external/​Java.Interop/​src/​Java.Interop/​Java.Interop/​JniPeerMembers.JniFields.tt Updates field-access generation.
external/​Java.Interop/​src/​Java.Interop/​Java.Interop/​JniPeerMembers.JniFields.cs Regenerates field accessors.
external/​Java.Interop/​src/​Java.Interop/​Java.Interop/​JniPeerMembers.cs Scopes native type-name construction.
external/​Java.Interop/​src/​Java.Interop/​Java.Interop/​JniObjectReferenceControlBlock.cs Scopes native allocation and access.
external/​Java.Interop/​src/​Java.Interop/​Java.Interop/​JniNativeMethodRegistration.cs Removes ineffective type-level unsafe modifier.
external/​Java.Interop/​src/​Java.Interop/​Java.Interop/​JniMethodInfo.cs Scopes debug UTF-8 pointer access.
external/​Java.Interop/​src/​Java.Interop/​Java.Interop/​JniMarshal.SafeInvoke.tt Updates callback wrapper generation.
external/​Java.Interop/​src/​Java.Interop/​Java.Interop/​JniMarshal.SafeInvoke.cs Regenerates callback wrappers.
external/​Java.Interop/​src/​Java.Interop/​Java.Interop/​JniEnvironment.Types.cs Adds bounded class-name termination.
external/​Java.Interop/​src/​Java.Interop/​Java.Interop/​JniEnvironment.Strings.cs Scopes JNI string pointer operations.
external/​Java.Interop/​src/​Java.Interop/​Java.Interop/​JniEnvironment.Object.cs Scopes native object construction.
external/​Java.Interop/​src/​Java.Interop/​Java.Interop/​JniEnvironment.Members.Utf8.cs Reclassifies UTF-8 member lookups.
external/​Java.Interop/​src/​Java.Interop/​Java.Interop/​JniEnvironment.cs Scopes environment pointer operations.
external/​Java.Interop/​src/​Java.Interop/​Java.Interop/​JniBuiltinMarshalers.tt Updates built-in marshaler generation.
external/​Java.Interop/​src/​Java.Interop/​Java.Interop/​JniBuiltinMarshalers.cs Regenerates built-in marshalers.
external/​Java.Interop/​src/​Java.Interop/​Java.Interop/​JniArgumentValue.cs Classifies explicit-layout union fields.
external/​Java.Interop/​src/​Java.Interop/​Java.Interop/​JavaPrimitiveArrays.tt Updates primitive-array generation.
external/​Java.Interop/​src/​Java.Interop/​Java.Interop/​JavaPrimitiveArrays.cs Regenerates primitive-array wrappers.
external/​Java.Interop/​src/​Java.Interop/​Java.Interop/​JavaObjectArray.cs Uses valid base initialization.
external/​Java.Interop/​src/​Java.Interop/​Java.Interop/​JavaObject.cs Reworks initialization and unsafe scopes.
external/​Java.Interop/​src/​Java.Interop/​Java.Interop/​JavaException.cs Reworks initialization and hash dispatch.
external/​Java.Interop/​src/​Java.Interop/​Java.Interop/​JavaArray.cs Adds safe initialization constructors.
external/​Java.Interop/​src/​Java.Interop/​Java.Interop.csproj Enables preview memory-safety rules.
external/​Java.Interop/​build-tools/​jnienv-gen/​Generator.cs Generates scoped and caller-facing unsafe contracts.

🧠 Review effort: Balanced

Comment thread external/Java.Interop/src/Java.Interop/Java.Interop/JniRuntime.cs
Comment thread external/Java.Interop/build-tools/jnienv-gen/Generator.cs Outdated
Comment thread external/Java.Interop/src/Java.Interop/Java.Interop/JniEnvironment.Types.cs Outdated
@simonrozsival
simonrozsival added this pull request to stack #13079 October 12, 2026 09:18
@simonrozsival

Copy link
Copy Markdown
Member Author

@dalexsoto review

@simonrozsival simonrozsival changed the title [Java.Interop] Opt into updated C# memory-safety rules [Unsafe][Java.Interop] Opt into updated C# memory-safety rules Oct 12, 2026
@simonrozsival simonrozsival added the ready-to-review This PR is ready to review/merge, I think any CI failures are just flaky (ignorable). label Oct 12, 2026
Comment thread external/Java.Interop/src/Java.Interop/Java.Interop.csproj

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

The complete 39-file correctness review, separate end-to-end integration/completeness pass and final coexistence search leave two caller-contract omissions under the newly enabled memory-safety model. The accepted UTF-8 zero-copy design, explicit direct-buffer/critical-array contracts, safe address-only return, withdrawn copy tests and preview-removal follow-up are not being relitigated. The underlying native hazards below predate this PR; the current defects are their safe classification in this migration.

[P1] Require a caller contract for DefineClass's native buffer (Generator.cs:241-243). Its const jbyte* buffer maps to IntPtr, and the operation entry has no CallerSafetyRequirements, so both public and raw generated declarations become safe-callable. Checking only a nonzero address cannot establish that bufferLength bytes are readable and remain valid/pinned while JNI parses them. A direct opted-in caller can pass an invalid address or an oversized length from safe code; the JNI implementation forwards that address/length into ClassFileStream's native reads. Add the existing per-operation safety metadata for readable bounds and backing-memory validity/pinning, regenerate public/raw contracts and safety docs, and reconcile the API baseline. The managed byte-array wrapper can remain safe because it supplies its own pinned, bounded storage.

[P1] Declare or discharge the direct native-registration callback lifetime (JniEnvironment.Types.cs:239-296). The delegate-array path is newly safe-classified, but it converts delegates with Marshal.GetFunctionPointerForDelegate, publishes the addresses to JNI, and retains the array only through GC.KeepAlive(methods) at the end of the call. CoreCLR's conversion creates a weak delegate handle, while JNI retains the addresses for later invocation. A captured delegate passed directly can therefore become collectible after registration while Java still holds its callback address. The forwarding two-argument overload does not add a persistent root. Either retain delegates for the entire registration lifetime, including applicable failure/partial-registration paths, or mark both direct delegate-array overloads requires-unsafe and document rooting/unregistration obligations, updating the API baseline and dependent calls consistently. Higher-level ownership-providing wrappers should remain safe only where they discharge those obligations; their successful rooting path is distinct from these direct APIs.

Both findings are static source evidence, not executed crash/GC or consumer probes. All 42 refreshed exact-head checks succeeded, but they do not establish these direct caller contracts. No additional high-confidence blocker remains after consolidation.

@simonrozsival

Copy link
Copy Markdown
Member Author

@dalexsoto Addressed both findings in db6718c.

DefineClass now uses the existing operation-level safety metadata to make both generated public/raw wrappers require unsafe. Its XML <safety> specifies readable initialized bounds, a nonnegative length, and live/stable backing storage (pinned when managed). The public API baseline is updated; JniType.DefineClass(byte[]) remains safe.

Both direct delegate-array Types.RegisterNatives overloads now require unsafe and document rooting through JNI's registration lifetime, including partial/failed registration and in-flight callbacks. GC.KeepAlive is in the exceptional cleanup path as well, but is not presented as a persistent root.

Kept JniType.RegisterNativeMethods safe by owning a snapshot of every attempted registration before calling JNI, with its owner already rooted by the runtime. Repeated attempts preserve earlier delegates; caller array mutations cannot remove roots. Successful unregistration releases the snapshots, while failed unregistration/disposal retains them. Runtime tracking retains each owner independently rather than assuming distinct JNI handles, and cleanup no longer removes roots before unregistering.

make leeroy CONFIGURATION=Debug passed. Actual opted-in consumers reject all three direct calls from safe code (three CS9362 diagnostics), accept local unsafe calls and both safe managed wrappers, and audit the new contracts in all six packed API 37/37.1/37.2 implementation/reference assemblies. Emitted safety XML passed. The four new captured-delegate/GC/mutation/repeat/partial-failure cases passed; the complete CoreCLR Debug arm64 runtime suite passed with 939 passed, 7 skipped, and 0 failed. The fresh owned emulator/fixture was removed afterward; no shared emulator or physical device was modified. Release/NativeAOT and other device ABIs were not rerun for this follow-up.

@dalexsoto

Copy link
Copy Markdown
Member

In response to @simonrozsival's request: I am deferring this review because the PR head changed from reviewed b4921c3 to d8b67d8 before submission. The safety check blocked the approval before posting, and the newer revision has not received complete review coverage. No review decision was submitted and this context is not marked complete.

@simonrozsival

Copy link
Copy Markdown
Member Author

@dalexsoto review

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

The earlier DefineClass and delegate-rooting findings are fixed, and the accepted UTF-8 zero-copy design, address-only return, runtime-creation deferral and preview follow-up are not being relitigated. Complete current-head correctness and end-to-end reviews leave two caller-contract omissions. Both were discoverable at the earlier reviewed revision; they are prior-review completeness misses, not defects introduced by the ancestor refresh.

[P1] Do not hide borrowed UTF-8 pointer reads behind safe remapping APIs. JniRuntime.JniTypeManager.cs:19-25 performs an unbounded native string scan inside a local unsafe block. The public ReplacementMethodInfo pointer setters and safe ToString() at lines 120-122 expose this read, and the protected GetReplacementMethodInfoCore(IntPtr, ...) path at lines 441-442 accepts another caller-owned pointer without a caller-unsafe contract. An ordinary IntPtr(-1) property value reaches the scan without JNI runtime creation, so the accepted runtime-creation deferral does not cover it. Keep ToString() safe by avoiding borrowed-pointer dereferences or retaining owned managed diagnostic strings; give the direct protected pointer overload an enforceable unsafe contract, lifetime documentation and matching baseline/caller changes.

[P1] Discharge callback ABI compatibility before safe registration publishes it. JniType.cs:208-213 now roots snapshots correctly, but its safe wrapper still accepts an arbitrary Delegate for a JNI descriptor. The conversion path does not validate descriptor/delegate ABI compatibility. A callback with ref int in the third position can be registered for the existing (II)I method: JNI supplies a jint value while the CLR thunk interprets it as an address, potentially creating an invalid managed reference. The delegate can remain fully rooted, so lifetime ownership does not resolve this independent obligation. Validate supported parameter, return, calling-convention and marshaling shapes before publication, or expose/document the remaining ABI obligation through an enforceable unsafe boundary.

All 41 changed files, generated/API/XML parity and the separate integration/completeness pass were covered; no additional high-confidence blocker remains after consolidation. These findings are static source evidence; no invalid pointer or callback was executed.

@simonrozsival

Copy link
Copy Markdown
Member Author

Do not hide borrowed UTF-8 pointer reads behind safe remapping APIs.

Fixed in 5105d3e: ReplacementMethodInfo.ToString() prints the three pointer addresses without scanning their backing storage. The direct protected pointer overload now requires unsafe, with XML lifetime requirements; its internal forwarding path, cached-pointer caller, override and API baseline are updated. Managed diagnostic strings and the managed fallback for valid literal-backed lookup are preserved.

Discharge callback ABI compatibility before safe registration publishes it.

Made JniType.RegisterNativeMethods caller-unsafe, while retaining its snapshot/runtime-root ownership fixes. Its <safety> contract now explicitly requires compatible JNI parameter/return types, leading environment/receiver pointers, calling convention and marshaling attributes; both direct registration overloads document the same ABI obligation. No restrictive validator or change to accepted delegate/marshaling shapes was added.

make leeroy CONFIGURATION=Debug and the final Java.Interop/API analyzer build passed. Actual consumers against all three APIs' implementation/reference assemblies reject the raw-pointer lookup and both ordinary and incompatible-by-ref owned-registration safe calls with three CS9362 diagnostics; positive scoped calls, all six PE/XML audits, safe arbitrary-pointer diagnostics and valid literal fallback passed. Six new device cases passed; the full CoreCLR Debug/arm64 suite passed with 974 passed, 7 skipped and 0 failed, including the existing captured-delegate rooting cases. The unsafe callback counterexample was compiled only, never registered or invoked; the owned emulator/fixture was removed afterward. Release/NativeAOT device lanes and other ABIs were not rerun for this follow-up.

@simonrozsival simonrozsival removed the ready-to-review This PR is ready to review/merge, I think any CI failures are just flaky (ignorable). label Oct 13, 2026
@dalexsoto

Copy link
Copy Markdown
Member

@simonrozsival Not reviewing this request in this iteration: The head changed during this review from 5105d3e to e08b310. No review is being submitted for the partially inspected old revision; the updated head remains tracked for a fresh-context review.

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

Both prior blockers are resolved: remapping diagnostics no longer dereference borrowed storage, raw remapping caller contracts propagate through forwarding, cached callers and the product override, and native registration exposes the accepted caller-unsafe ABI boundary while preserving snapshots, rooting and unregister-before-unroot lifetime ordering.

The complete current change, generated outputs, API baselines and downstream consumers received correctness, integration and final independent-blocker passes; no high-confidence blocker remains. Previously accepted compromises and follow-ups remain accepted.

This is source approval, not a claim of green CI or merge readiness. The inspected macOS failure occurs during SDK first-use before project evaluation, with the relevant startup/configuration inputs unchanged; its underlying cause is not being classified as transient, flaky or retry-safe.

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

The complete current 46-file revision was re-reviewed against the previously approved head. The widened pointer-publication contracts, borrowed diagnostic construction, raw callback layout, primitive-array storage and thread-attachment changes preserve the inspected ownership, lifetime, ABI and compatibility behavior. Prior fixes remain intact, generated outputs/API/XML contracts agree, and the independent final search found no high-confidence blocker. Existing accepted compromises and follow-ups remain accepted.

This is source approval, not a claim of green CI or merge readiness. Both investigated macOS failures occur during SDK first-use before project evaluation, and the startup/configuration inputs were verified unchanged. The underlying mutex cause remains unresolved; neither failure is being labeled transient, flaky or retry-safe.

@simonrozsival
simonrozsival force-pushed the simonrozsival-csharp-memory-safety branch from 114cb3a to 639ebe9 Compare October 14, 2026 14:00
Comment thread external/Java.Interop/src/Java.Interop/PublicAPI.Unshipped.txt Outdated

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

This corrects two completeness misses in my earlier review. Both blockers already existed in the previously reviewed, unchanged source; they were not introduced by a new revision. The complete 46-file correctness pass, updated integration against actual main, candidate reconciliation and final independent search are complete.

  1. Preserve the released native-int remapping signatures. The APIs questioned in the new thread are public/protected, not internal. The three public TargetJniTypeUtf8, TargetJniMethodNameUtf8 and TargetJniMethodSignatureUtf8 properties change from IntPtr to byte*, and the protected virtual GetReplacementTypeInfoCore and raw GetReplacementMethodInfoCore hooks likewise replace native-int types with pointer types. That replaces eight managed callable signatures: six property accessors and two virtual methods. See JniRuntime.JniTypeManager.cs:51-69, line 382, and line 467.

PublicAPI.Unshipped.txt alone is not shipping evidence: the old shapes are in the published .NET 11 RC2 workload 37.2.0-rc.2.84, exact 801f9ecb source, and its reference/runtime pack inputs include Java.Interop.dll. Old native-int MemberRefs and custom overrides do not bind the replacement pointer signatures. Equal pointer width does not preserve managed member binding. This finding is limited to RC2 consumers: these additions were absent from the inspected stable .NET 10 and .NET 11 RC1 sources. Retain the old signatures and override dispatch, bridging to distinct typed-pointer properties or additional hooks. This preserves the accepted unsafe caller obligations without reversing the safety strategy or adding restrictive callback validation; no accepted exception for this RC2 break was found.

  1. Gate the positive metadata test on the SDK library's capability, not the APK's DEBUG symbol. JniCallerSafetyTests.cs:167-172 uses the test assembly's DEBUG setting, then unconditionally reads Name/Signature at lines 192-194. The separately compiled SDK Java.Interop getters deliberately throw NotSupportedException without that library's DEBUG metadata support; this production contract predates the PR. Exact-head build 1635200 task log 1666 contains all three BorrowedMethodMetadataReadsLiveBuffersLazily variants failing in get_Name at line 192. Variant 2 supplies a managed string, so this is not an invalid borrowed-pointer explanation. Gate or move this positive test to a configuration that actually provides metadata-enabled Java.Interop; do not change the intentional production getter or suppress arbitrary exceptions to make it pass.

The CI merge ancestry and complete three-failure log were verified. Integration with the newer runtime/startup target and subsequent main changes was source-verified; no checkout, Git merge, build, test, compiler or device operation was executed by this reviewer.

@simonrozsival

Copy link
Copy Markdown
Member Author

Preserve the released native-int remapping signatures.

Fixed in 0e8b789d24ad54ed485711786c5b4af1a6c5a009. The three original IntPtr properties and both native-int virtual hooks are restored, including all eight callable signatures and existing custom override/base-call dispatch. They are warning-only [Obsolete] compatibility APIs. The user explicitly selected preserving their safe-call behavior: these legacy boundaries are documented exceptions to caller-unsafe enforcement, not an assertion that arbitrary addresses are safe.

New TargetJni*Utf8Pointer setters, the raw byte* method hook, and GetReplacementTypeInfoPointerCore enforce unsafe; internal storage and product consumers stay typed. The producer's distinct name avoids breaking existing out var calls through overload ambiguity.

A consumer was forced-rebuilt against Java.Interop.dll from the actual published Microsoft.Android.Ref.37 37.2.0-rc.2.84 NuGet pack, then its unchanged binary ran against the fixed Debug and Release libraries. All six old property MemberRefs, both old hook MemberRefs, old custom overrides/base calls, and new typed-to-legacy dispatch passed. All eight released signatures and method attributes also match across the nine rebuilt implementation/reference/runtime-pack assemblies for APIs 37/37.1/37.2; new typed callers still produce the expected six CS9362 diagnostics per implementation/reference pair.

Gate the positive metadata test on the SDK library's capability, not the APK's DEBUG symbol.

Removed the APK #if DEBUG gate. The three positive read cases now inspect the loaded library's diagnostic metadata storage and explicitly skip only when that capability is absent. The production Name/Signature getters are unchanged; no arbitrary exception suppression was added. The same Debug-built test source passed 32 cases with Debug Java.Interop and 29 cases with exactly three capability skips with an actual Release Java.Interop build.

Local validation:

  • make leeroy CONFIGURATION=Debug MSBUILD_ARGS='-m:1 -p:UseSharedCompilation=false' — passed all APIs and workload setup.
  • ./dotnet-local.sh build external/Java.Interop/src/Java.Interop/Java.Interop.csproj --no-restore -c Release -m:1 -p:UseSharedCompilation=false -p:JINetCoreLibVersion=11.0.0.0 -v:minimal — passed, zero warnings/errors; the version matches the prepared Debug product inputs for this isolated Release-library check.
  • Canonical Mono.Android.NET-Tests.csproj Debug/arm64 CoreCLR install and MTP full run — 1,000 passed, 7 existing skips, 0 failed.
  • Canonical Debug/arm64 PublishAot=true, Optimize=true, IncludeCategories=JniCallerSafety install/MTP run — 21 applicable cases passed, with seven existing platform/NUnit/assembly-level AOT warnings. A new NUnit object-equality assertion initially hit its unsupported NativeAOT interface-map reflection path; it now calls the same strongly typed Equals directly, and the final assertion passed host and NativeAOT reruns without weakening the equality check.

The final NativeAOT installed APK was byte-identical to the canonical signed artifact, and the owned fixture/emulator was removed afterward. Full Release SDK/device coverage, other ABIs, and the full NativeAOT suite were not rerun; this is local validation, not a claim that new-head CI is green.

@dalexsoto

Copy link
Copy Markdown
Member

@simonrozsival Not reviewing this request in this iteration: The head changed during the final publication guard from 0e8b789 to 951fa3a. The guard blocked the approval before posting; no review decision was submitted for the old revision. The updated head remains tracked for a fresh complete-context review.

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

The complete current 46-file source and separate integration review verifies both previous blockers fixed. All eight released native-int callable shapes and old override/base-call dispatch remain restored under the explicitly accepted warning-obsolete safe-call compatibility policy. Loaded-library capability gating for exactly three positive diagnostic tests remains correct without changing production getters or suppressing arbitrary failures.

The actual new revision is not documentation-only: its five-file hook rename was inspected across declarations, internal calls, product overrides, API rows, reflection lookups and fixtures. GetReplacementTypeInfoUnsafe and GetReplacementMethodInfoUnsafe retain caller-unsafe typed boundaries and acyclic typed-to-legacy-to-managed dispatch; old out-var resolution remains intact. Forty-one unchanged components, all current patches and canonical generated/output/API/XML/product inputs retain the complete prior proof. The independent final search found no high-confidence blocker. Earlier accepted UTF-8, array, callback ABI/lifetime, runtime-pointer and preview policies remain accepted.

This is source approval, not green CI or merge readiness. All 41 exact new-head check contexts were read: rollup was pending with no failed context at closing. Build1636471's separately investigated DNS/upload-OOM failures tested the earlier0e8 head and are not treated as new-head results or execution proof. No retry was initiated, and no unknown failure is called flaky, transient or retry-safe.

Read-only source/API/artifact analysis only. Author consumer/PE/device results remain author evidence; this reviewer executed no repository source, compiler, generator, test or device operation. The earlier0e8 approval was blocked before posting; this review applies only to951.

simonrozsival and others added 16 commits October 16, 2026 09:48
Keep the UTF-8 compatibility preparation ready for memory-safety
activation by explicitly scoping native string scans and raw hook calls.
A member unsafe declaration does not provide an unsafe implementation
context under the updated rules. Preserve all existing API contracts.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Keep JNI primitive-array ownership pointer-backed without changing the
public IntPtr compatibility getter, ref-return ABI, or release policy.
Document acquisition and array lifetimes for borrowed references while
keeping typed and generic GetElements acquisition safe-callable.

Replay standalone f8dd583bebb1c6a645927e12197ea595c744d3ef onto the
verified UTF8 parent for the user-directed strict stack.  Preserve the
inherited callback, constructor, and UTF8 preparation unchanged.

Canonical T4 regeneration/parity, parent and integrated compiler-only
checks, persisted test compilation, and public ABI comparison passed.
Prior SDK/APK evidence belongs to the standalone snapshot, not this
integration commit.  Device execution remains pending permission.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Keep the primitive ref-return indexers explicitly unsafe for callers,
and put their pointer dereferences in local unsafe blocks as well.
The updated memory-safety mode does not use the declaration modifier
as the implementation body context.

Keep disposed/bounds guards outside the local block and preserve safe
GetElements acquisition, ref-return ABI, and pointer owner lifetimes.
Regenerate the paired source canonically from JavaPrimitiveArrays.tt.

Legacy compiler-only builds and persisted test compilation pass, with
all 1032 public signatures and eight ref-return ABIs unchanged.
T4 generation parity passes.  Earlier SDK/APK evidence remains tied
to its original snapshot; new-mode final activation and device
validation remain coordinator-owned and are not claimed here.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Keep owned pinning, native allocations, and JNI calls inside local unsafe scopes without enabling updated memory-safety enforcement. Preserve native layouts and callback lifetime obligations, with focused layout regression coverage.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Add implementation-local unsafe regions for raw UTF-8 lookups, callback registration, native method dispatch, and UTF-8 buffer cleanup. Preserve existing caller declarations, caching, callback snapshots, and try/finally cleanup while preparing for separate activation of updated memory-safety rules.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Classify generated JNI caller obligations separately from implementation
unsafe scopes so later memory-safety activation does not leak unmanaged
buffer obligations onto managed alternatives. Keep canonical T4 inputs
and regenerated outputs paired, with bounds and retained-lifetime docs.

Integrate onto the verified owned-memory layer while preserving lower
callback roots, constructor activation, UTF-8 compatibility, and primitive
array borrowing. Reuse the existing preview parser preparation without
enabling updated memory-safety rules or changing API baselines.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Emit explicit unsafe implementation blocks for generated UTF8 cleanup.
Unsafe member declarations do not provide a body context under the updated
memory-safety rules, including for Marshal.ZeroFreeCoTaskMemUTF8.

Add matching local contexts to raw class-name cleanup, registration
forwarding, descriptor construction, dispatch and cleanup. Preserve the
existing call order, finally boundaries, callback roots and caller ABI
contracts. Leave assembly rules disabled for this preparation layer.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Enable the updated model locally in both build-task assemblies and the typemap tool. Keep pinned encoding and path traversal implementation-owned, and document the borrowed PE resource stream caller lifetime.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Localize JNI argument storage, invocation, marshaling and cleanup without making managed overrides or interface implementations caller-unsafe. Keep caller contracts distinct from SourceWriter body regions and preserve both callback formats.

Add the approved None-only deferred Throwable initialization path throughout the affected inheritance hierarchy, preserving peer-constructor hooks and auditing imported-base capability. Update immediate constructor API baselines and focused dual-mode compiler/PE, snapshot, host and device coverage. Product memory-safety activation remains off.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Remove build-machine SDK/compiler paths from generator test assembly metadata. Compile dual-mode fixtures through the supported dotnet build interface with explicit references and isolated temporary projects, without depending on the SDK Roslyn directory layout.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Keep None-only validation before base initialization in a private static helper, preserving the existing CopyAndDispose result, ArgumentException message/parameter and deferred-peer lifecycle.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Scope setter implementation regions whenever the emitter owns JNI reference cleanup, not only for string fields. Preserve safe property contracts and existing preparation, assignment and finally release order.

Classify fixture DeleteLocalRef as caller-unsafe and exercise instance/static object and object-array fields with actual legacy/updated SDK compilation and implementation/reference PE checks. Regenerate four token-equivalent setter snapshots.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Terminate the owned dotnet host and its descendants before fixture cleanup so timed-out MSBuild/compiler children do not survive the host. Preserve the existing timeout assertion and exit wait.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Extract only the published JNI runtime preparation delta onto pinned main, preserving the current typed GC-bridge startup and deleted internal native surface. Keep legacy constructor initialization and legacy memory-safety compilation. Relocate the runtime-creation activation assertion and unsafe API baseline expectations to the preserved session patch for final Java.Interop activation.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Move the existing Spliterator source Update before the unrelated Java fixture Updates. Evaluated item identities and all metadata remain identical; preserve current-main additions without importing new upstream source.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Enable project-local caller enforcement after the focused preparation layers. Keep address getters and managed acquisition safe, enforce borrowed setters and raw ABI contracts, and preserve the warning-obsolete RC2 IntPtr APIs.

Retain compiler and metadata regressions for runtime creation, borrowed-pointer publication, primitive-array references, and safe legacy compatibility boundaries.

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

Copy link
Copy Markdown
Member Author

Superseded by the focused native stack 13185, ending at #13192. Please review the individual layers rather than this combined 249-file diff.

Active order: #13156 → #13183 → #13184 → #13186 → #13187 → #13188 → #13189 → #13190 → #13191 → #13192. Final activation is only 13 files, +483/-213; the tooling, binding, and runtime changes remain separate preparation layers.

The exact validated commit chain is unchanged. Full local SDK, compatibility, host, and all four runtime-suite results are recorded in #13192. The earlier #13078, #13104, and #13105 merge records were into this feature branch, not main. Historical stack 13079 and its review discussions are retained.

@dalexsoto

Copy link
Copy Markdown
Member

@simonrozsival Not reviewing this request in this iteration: The PR was closed while revision cd9e12f was being inspected, before the exhaustive review and supporting evidence were complete. I stopped that review without submitting a decision; the earlier approval of revision 951fa3a is not being extended to this revision. If the PR is reopened, the current revision remains tracked for a fresh complete review.

simonrozsival added a commit that referenced this pull request Oct 20, 2026
Audit JNI callback safety contracts and fix the teardown bugs found during that audit, without redesigning registration ownership. Published revision: `0074a9bb763f0cad7a9cf1ac88c18c9436cba5d9`; **six files, +384/-19**.

### Safety contracts and retained behavior

Add caller-facing `unsafe` modifiers to the managed-array registration APIs, explicit implementation `unsafe { }` blocks with concrete `// SAFETY:` comments, and documentation for callback ABI, array aliases, native storage lifetime, and in-flight calls. The raw descriptor constructor only stores addresses; its remarks describe requirements when registering those addresses. Compiler opt-in remains in the final activation layer.

`JniType.RegisterNativeMethods()` still calls JNI **before** retaining the original array and tracking the owner, and does so only on success. Only the last successful array is remembered. There is no clone, history collection, or duplicate-registration guard. The caller must not register again on the same managed owner before successful unregistration, or mutate the transferred array/entries through any alias while callbacks can run. These are safety obligations, not runtime checks.

**Partial-registration ownership/recovery is explicitly deferred by maintainer decision.** Existing JNI exceptions propagate unchanged. Failed attempts do not transfer array ownership, even if JNI installed some callbacks; callers must keep those delegates rooted until successful unregistration and callback completion. Managed preparation failure leaves previous successful state unchanged. No callback plumbing, phase flag, automatic rollback, retry, or new exception policy is added.

### Targeted cleanup fixes

- Unregister native methods before untracking the owner or releasing its JNI reference. Successful unregistration clears the retained array; failure preserves it.
- Snapshot tracked references without pre-clearing the registry, so failed disposal does not prematurely drop roots.
- Run `GC.KeepAlive(methods)` in `finally`, including exceptional marshalling exits.

Callers must prevent new calls and wait for running callbacks before unregistration/disposal. Raw callback storage must remain callable through successful unregistration **and completion of all in-flight calls**.

### JNI handles versus object identity

The existing `Dictionary<IntPtr, IDisposable>` remains. Its key, `JniType.PeerReference.Handle`, is an opaque JNI global-reference handle—not a canonical Java-class identifier. Different global references to the same class can have different handle values; JNI's `IsSameObject` establishes underlying identity. The dictionary therefore does not deduplicate wrappers by Java-class identity. No `HashSet` conversion or class-wide registration-owner registry is introduced.

Separate wrappers must coordinate class-wide JNI operations. The single-registration obligation applies to this managed delegate-array API, not the separate raw-span/UCO path used by the production typemap.

### Validation and remaining limits

**The eight current regression cases are compiled, not run on Android.** They cover successful array/delegate/owner retention and release, real disposal with native unregistration checked through an independent class reference, owner collection, preparation-failure retry and preservation of existing registrations, independent-owner disposal safety, and existing Java exception propagation. The failure test keeps caller-owned array/delegate roots through explicit cleanup; it does not promise partial-failure ownership or recovery. Tests remain `NativeAOTIgnore` because they require dynamic callbacks. Controlled unregistration-failure injection coverage is explicitly blocked by the maintainer's decision not to add a production test seam.

Current-revision host compilation used `DOTNET_PROCESSOR_COUNT=2` and `CMAKE_BUILD_PARALLEL_LEVEL=2`:

```sh
./dotnet-local.sh build external/Java.Interop/src/Java.Interop/Java.Interop.csproj -c Debug -m:1 -nr:false -p:UseSharedCompilation=false -v:minimal -bl:bin/BuildDebug/layer1-defer-partial-registration-host.binlog
# Passed: 0 errors; 1 Microsoft.Build.Tasks.Git warning: "Repository has no commit."
./dotnet-local.sh build tests/Mono.Android-Tests/Java.Interop-Tests/Java.Interop-Tests.NET.csproj -c Debug -m:1 -nr:false -p:UseSharedCompilation=false -p:_AndroidRuntime=CoreCLR -p:RuntimeIdentifier=android-arm64 -v:minimal -bl:bin/BuildDebug/layer1-defer-partial-registration-fixture.binlog
# Passed: 0 warnings, 0 errors; fixture compilation against the existing SDK.
git diff --check
# Passed before committing.
```

No fresh full SDK/device validation or integrated opt-in metadata proof validates this revision. The host project remains `Features=''`, `LangVersion=14.0`. Earlier four-case callback device results at `3d3ae5ad5d`/`31001bbbfc` and the four runtime configurations recorded for final stack snapshot `cd9e12f2d4` are **historical evidence for the earlier implementation**, not passes for `0074a9bb76`. The intermediate `ee4e65ae4c` duplicate-rejection policy was host-compiled, not device-tested, and is removed here.

The latest audit revisions for the nine downstream layers are **local and unpushed**, paused for churn reassessment. Their published branches are not synchronized with these revisions; integrated runtime validation remains pending. This description update changes no source, PR base, branch, or native stack metadata.

This is the callback preparation layer extracted from [#13070](#13070), not the final compiler activation. The extraction used baseline `888dfb3f87eb0f34c42075e0a3d8e78db4c3d54d` and source `951fa3a2dc588e15ff63eea4565d1dd14a30f036`; original local validation used `ca6c659c138f160104ab4ade135a01dc8c113530`, and the callback commit was later prepared on `f021275077331b36d936ca7f63753b24daeba21a`. No issue is closed by this preparatory PR.
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