Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
31001bb
[Java.Interop] Retain JNI callback registration owners
simonrozsival Oct 15, 2026
ab9e1ad
[Java.Interop] Prepare managed peer construction and contexts
simonrozsival Oct 15, 2026
986a13f
[Java.Interop] Keep managed construction safe for callers
simonrozsival Oct 16, 2026
88e7342
[Java.Interop] Prepare UTF-8 remapping contracts
simonrozsival Oct 15, 2026
ca48554
[Java.Interop] Add explicit unsafe remapping body scopes
simonrozsival Oct 16, 2026
c314a9d
[Java.Interop] Prepare primitive-array borrowing contracts
simonrozsival Oct 15, 2026
4d2f680
[Java.Interop] Scope primitive ref-indexer unsafe bodies explicitly
simonrozsival Oct 16, 2026
120c7fc
[Unsafe][Java.Interop] Prepare owned JNI memory scopes
simonrozsival Oct 15, 2026
ab6fed2
[Unsafe][Java.Interop] Scope native lookup implementations
simonrozsival Oct 16, 2026
ce28d72
[Unsafe][Java.Interop] Prepare generated JNI caller contracts
simonrozsival Oct 15, 2026
aa8f935
[Unsafe][Java.Interop] Scope native UTF8 cleanup and registration
simonrozsival Oct 16, 2026
bafafa0
[Unsafe] Opt tooling into updated C# memory-safety rules
simonrozsival Oct 15, 2026
4437899
[generator] Prepare safe managed binding contracts
simonrozsival Oct 12, 2026
0c6c041
[tests] Resolve fixture compiler through the SDK
simonrozsival Oct 12, 2026
d491e81
[Mono.Android] Extract empty-peer option validation
simonrozsival Oct 12, 2026
f7ba478
[generator] Audit reference-field setter cleanup
simonrozsival Oct 12, 2026
898022e
[tests] Kill fixture compiler process tree on timeout
simonrozsival Oct 13, 2026
bf917a5
Prepare standalone JNI and runtime safety contracts
simonrozsival Oct 15, 2026
88a2829
Avoid adjacent fixture item conflicts with current main
simonrozsival Oct 16, 2026
cd9e12f
[Unsafe][Java.Interop] Opt into updated C# memory-safety rules
simonrozsival Oct 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 54 additions & 14 deletions build-tools/jnienv-gen/Generator.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;

Expand Down Expand Up @@ -54,7 +55,7 @@ static void GenerateFile (TextWriter o)
{
o.WriteLine ("// Generated file; DO NOT EDIT!");
o.WriteLine ("//");
o.WriteLine ("// To make changes, edit monodroid/tools/jnienv-gen and rerun");
o.WriteLine ("// To make changes, edit build-tools/jnienv-gen and rerun with -o=src/Mono.Android/Android.Runtime/JNIEnv.g.cs");
o.WriteLine ();
o.WriteLine ("using System;");
o.WriteLine ("using System.Runtime.ExceptionServices;");
Expand Down Expand Up @@ -91,36 +92,51 @@ static void GenerateJniEnv (TextWriter o)
o.WriteLine ("\t\t{");
o.WriteLine ("\t\t\tif (array == null)");
o.WriteLine ("\t\t\t\treturn IntPtr.Zero;");
o.WriteLine ("\t\t\tJniObjectReference result;");
o.WriteLine ("\t\t\tresult = JniEnvironment.{0}.{1} (array.Length);", entry.DeclaringType, entry.Name);
o.WriteLine ("\t\t\tCopyArray (array, result.Handle);");
o.WriteLine ("\t\t\treturn result.Handle;");
o.WriteLine ("\t\t\tunsafe {");
o.WriteLine ("\t\t\t\t// SAFETY: result is a newly allocated array of the matching primitive type and array.Length elements.");
o.WriteLine ("\t\t\t\tJniObjectReference result;");
o.WriteLine ("\t\t\t\tresult = JniEnvironment.{0}.{1} (array.Length);", entry.DeclaringType, entry.Name);
o.WriteLine ("\t\t\t\tCopyArray (array, result.Handle);");
o.WriteLine ("\t\t\t\treturn result.Handle;");
o.WriteLine ("\t\t\t}");
o.WriteLine ("\t\t}");
break;
case "CopyArray":
o.WriteLine ("\t\t/// <safety>");
o.WriteLine ("\t\t/// The JNI array handle in <paramref name=\"{0}\"/> must remain live on the current thread,",
entry.Name.StartsWith ("G", StringComparison.Ordinal) ? "src" : "dest");
o.WriteLine ("\t\t/// have the matching primitive element type, and contain at least as many elements as <paramref name=\"{0}\"/>.",
entry.Name.StartsWith ("G", StringComparison.Ordinal) ? "dest" : "src");
o.WriteLine ("\t\t/// </safety>");
o.Write ("\t\t{2} static unsafe {0} {1} (", entry.ReturnType.ManagedType, entry.ApiName, entry.Visibility);
if (entry.Name.StartsWith ("G", StringComparison.Ordinal)) {
o.WriteLine ("IntPtr src, {0} dest)", entry.Parameters [3].Type.ManagedType);
o.WriteLine ("\t\t{");
o.WriteLine ("\t\t\tif (src == IntPtr.Zero)");
o.WriteLine ("\t\t\t\treturn;");
var t = entry.Parameters [3].Type.ManagedType.Replace ("[]", "*");
o.WriteLine ("\t\t\tfixed ({0} __p = dest)", t);
o.WriteLine ("\t\t\t\tJniEnvironment.{0}.{1} (new JniObjectReference (src), 0, dest.Length, {2}__p);",
o.WriteLine ("\t\t\tunsafe {");
o.WriteLine ("\t\t\t\t// SAFETY: dest is pinned for its full length; the caller supplies a live, matching JNI array.");
o.WriteLine ("\t\t\t\tfixed ({0} __p = dest)", t);
o.WriteLine ("\t\t\t\t\tJniEnvironment.{0}.{1} (new JniObjectReference (src), 0, dest.Length, {2}__p);",
entry.DeclaringType,
entry.Name,
t == "byte*" ? "(sbyte*) " : "");
o.WriteLine ("\t\t\t}");
} else {
o.WriteLine ("{0} src, IntPtr dest)", entry.Parameters [3].Type.ManagedType);
o.WriteLine ("\t\t{");
o.WriteLine ("\t\t\tif (src == null)");
o.WriteLine ("\t\t\t\tthrow new ArgumentNullException (\"src\");");
var t = entry.Parameters [3].Type.ManagedType.Replace ("[]", "*");
o.WriteLine ("\t\t\tfixed ({0} __p = src)", t);
o.WriteLine ("\t\t\t\tJniEnvironment.{0}.{1} (new JniObjectReference (dest), 0, src.Length, {2}__p);",
o.WriteLine ("\t\t\tunsafe {");
o.WriteLine ("\t\t\t\t// SAFETY: src is pinned for its full length; the caller supplies a live, matching JNI array.");
o.WriteLine ("\t\t\t\tfixed ({0} __p = src)", t);
o.WriteLine ("\t\t\t\t\tJniEnvironment.{0}.{1} (new JniObjectReference (dest), 0, src.Length, {2}__p);",
entry.DeclaringType,
entry.Name,
t == "byte*" ? "(sbyte*) " : "");
o.WriteLine ("\t\t\t}");
}
o.WriteLine ("\t\t}");
break;
Expand Down Expand Up @@ -167,14 +183,22 @@ entry.Parameters [entry.Parameters.Length-1].IsParamArray &&
return;
}

o.Write ("\t\t{0} static unsafe {1} {2} ",
var safetyRequirements = GetCallerSafetyRequirements (entry, pointerArguments: generateParamsOverload);
WriteSafetyRequirements (o, safetyRequirements);
o.Write ("\t\t{0} static {3}{1} {2} ",
entry.Visibility,
entry.ReturnType.ManagedType,
entry.ApiName);
entry.ApiName,
safetyRequirements.Count == 0 ? "" : "unsafe ");
bool is_void = entry.ReturnType.ManagedType == "void";
WriteJniFunctionParameters (o, entry, generateParamsOverload);
o.WriteLine ("\t\t{");
o.Write ("\t\t\t");
bool needsUnsafeBody = generateParamsOverload || entry.Name == "NewDirectByteBuffer";
if (needsUnsafeBody) {
o.WriteLine ("\t\t\tunsafe {");
o.WriteLine ("\t\t\t\t// SAFETY: the caller supplies the documented member ABI, argument storage and native-memory lifetimes.");
}
o.Write (needsUnsafeBody ? "\t\t\t\t" : "\t\t\t");
if (!is_void)
o.Write ("return ");
var n = entry.Name;
Expand Down Expand Up @@ -211,24 +235,40 @@ entry.Parameters [entry.Parameters.Length-1].IsParamArray &&
if (IsMemberID (entry.ReturnType))
o.Write (".ID");
o.WriteLine (";");
if (needsUnsafeBody)
o.WriteLine ("\t\t\t}");
o.WriteLine ("\t\t}");

if (!generateParamsOverload)
return;
o.WriteLine ();
WriteSafetyRequirements (o, GetCallerSafetyRequirements (entry, pointerArguments: false));
o.Write ("\t\t{0} static {1}{2} {3} ",
entry.Visibility, "unsafe ", entry.ReturnType.ManagedType, entry.ApiName);
WriteJniFunctionParameters (o, entry, false);
o.WriteLine ("\t\t{");
o.WriteLine ("\t\t\tfixed (JValue* __p = {0}) {{", Escape (entry.Parameters [entry.Parameters.Length-1].Name));
o.WriteLine ("\t\t\t\t{0}{1} ({2}, __p);",
o.WriteLine ("\t\t\tunsafe {");
o.WriteLine ("\t\t\t\t// SAFETY: the argument array remains pinned for the call; method ABI and referenced-handle lifetimes remain the caller's responsibility.");
o.WriteLine ("\t\t\t\tfixed (JValue* __p = {0}) {{", Escape (entry.Parameters [entry.Parameters.Length-1].Name));
o.WriteLine ("\t\t\t\t\t{0}{1} ({2}, __p);",
is_void ? "" : "return ",
entry.ApiName,
string.Join (", ", entry.Parameters.Take (entry.Parameters.Length-1).Select (p => Escape (p.Name))));
o.WriteLine ("\t\t\t\t}");
o.WriteLine ("\t\t\t}");
o.WriteLine ("\t\t}");
}

static void WriteSafetyRequirements (TextWriter o, IReadOnlyList<string> requirements)
{
if (requirements.Count == 0)
return;
o.WriteLine ("\t\t/// <safety>");
foreach (var requirement in requirements)
o.WriteLine ("\t\t/// " + requirement);
o.WriteLine ("\t\t/// </safety>");
}

static bool IsObjectReferenceType (TypeInfo type)
{
switch (type.Type) {
Expand Down
32 changes: 32 additions & 0 deletions build-tools/jnienv-gen/JNIEnvEntries.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System.Collections.Generic;

namespace Xamarin.Android.JniEnv
{
partial class Generator
Expand All @@ -19,6 +21,36 @@ partial class Generator
const string ReflectionSupportCategory = "Reflection";
const string JavaVMCategory = "References";

static IReadOnlyList<string> GetCallerSafetyRequirements (JniFunction entry, bool pointerArguments)
{
var requirements = new List<string> ();
switch (entry.Name) {
case "NewDirectByteBuffer":
requirements.Add ("<paramref name=\"capacity\"/> must be non-negative and within JNI's direct-buffer capacity limits.");
requirements.Add ("<paramref name=\"address\"/> must address at least <paramref name=\"capacity\"/> bytes of accessible memory.");
requirements.Add ("The backing memory must remain allocated and accessible for every use of the returned Java buffer.");
return requirements;
}

foreach (var parameter in entry.Parameters) {
if (IsObjectReferenceType (parameter.Type)) {
requirements.Add (string.Format (
"<paramref name=\"{0}\"/> must be a live JNI {1} reference usable on the current thread for the entire call, or null only where this JNI operation permits it.",
parameter.Name.TrimStart ('@'), parameter.Type.Type));
} else if (IsMemberID (parameter.Type)) {
requirements.Add (string.Format (
"<paramref name=\"{0}\"/> must be a live {1} for the supplied receiver or class, with the static/instance kind and value/return ABI required by this operation.",
parameter.Name, parameter.Type.Type));
} else if (parameter.Type.ManagedType == "JValue*") {
requirements.Add (string.Format (
"<paramref name=\"{0}\"/> must supply all arguments in the member's declared JNI order and types; referenced objects must remain live.{1}",
parameter.Name,
pointerArguments ? " Pointer storage must remain readable for that argument count throughout the call." : ""));
}
}
return requirements;
}

static readonly JniFunction[] JNIEnvEntries = new JniFunction[]{
new JniFunction {
DeclaringType = VersionsCategory,
Expand Down
137 changes: 137 additions & 0 deletions external/Java.Interop/Documentation/Architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,143 @@ conversion and argument-state creation/cleanup. Primitive, string, and array
conversion remain supported. `JavaObject` and `JavaException` maintain managed
peers and their JNI reference ownership.

## Compiler-enforced caller contracts

Java.Interop opts into the preview compiler's `updated-memory-safety-rules`
feature. Its implementation and reference modules carry
`MemorySafetyRulesAttribute (2)`, and caller-unsafe members carry
`RequiresUnsafeAttribute`. Updated-rule consumers must acknowledge those
contracts at their call sites. Legacy consumers retain the existing compiler
behavior; the metadata does not validate native addresses or callback ABIs.

A declaration's `unsafe` modifier describes a caller obligation, not an unsafe
implementation context. Managed alternatives keep pinning, pointer access, and
native invocation inside explicit local `unsafe` blocks without transferring
those obligations to their callers. Mono.Android and customer-project opt-in
remain separate changes.

## Native callback ownership

`JniType.RegisterNativeMethods` snapshots and retains delegates from every attempted
registration, including repeated, failed, and partial registrations. The runtime
retains each registration owner until disposal. Successful unregistration releases
the owner's callback roots; failed unregistration preserves them. Callers must
quiesce native invocations and wait for all in-flight callbacks to complete before
unregistering or disposing the owner.

Direct `JniEnvironment.Types.RegisterNatives` calls do not retain callback roots
after returning. Their callers must keep delegates or raw callback storage callable
and rooted for as long as JNI can invoke them, including after failed or partial
registration. Quiesce native invocations before unregistration and release callback
storage only after successful unregistration and completion of all in-flight calls.

`JniNativeMethod` name/signature storage must remain readable and NUL-terminated
through registration. Its raw callback must match the JNI descriptor's ABI,
including the environment and receiver/class arguments.

## Borrowed UTF-8 metadata

Raw UTF-8 names and signatures are stored as `byte*`, not opaque `IntPtr`
values. The `ReplacementMethodInfo.TargetJni*Utf8Pointer` properties and the
`JniTypeManager.GetReplacementTypeInfoUnsafe` and
`GetReplacementMethodInfoUnsafe` hooks expose these borrowed buffers explicitly.
Their `<safety>` documentation specifies readable NUL-terminated storage and its
lifetime. Retained names must remain alive and unchanged for the runtime's
lifetime and every subsequent cached metadata read. Java.Interop does not own
or free them. Managed-string alternatives remain safe to construct and use;
non-null pointers take precedence over managed replacement names.

Peer members cache the borrowed type name and lazily cache its managed
conversion. In Debug, `JniMethodInfo` also reads borrowed names and signatures
lazily. Its raw constructors require an unsafe context, while its public
diagnostic getters use the lifetime established at construction. Remapping
equality, hashing, and `ToString` compare or format addresses without reading
their contents. JNI object handles and method/field IDs remain opaque handles.

The native-int `TargetJni*Utf8` properties and
`GetReplacementTypeInfoCore`/`GetReplacementMethodInfoCore` virtual hooks shipped
in .NET 11 RC2. They retain all six accessors and both virtual slots with their
original signatures and safe-call behavior as warning-obsolete compatibility
APIs. These are explicit exceptions to caller-unsafe enforcement, not proof
that an address is valid; their backing-memory obligations are unchanged.
Typed base hooks dispatch through the old virtual slots so existing custom
overrides and their base calls continue to run. The typed type-name producer
has a distinct name to avoid ambiguity in existing `out var` calls.

Typed pointer getters remain safe: reading an address does not dereference its
storage. Their setters and the typed raw hooks require unsafe callers, who must
establish the documented storage lifetime. The warning-obsolete native-int
compatibility APIs remain safe-callable under both compiler modes.

## Owned JNI implementation memory

Managed alternatives such as `JniType.DefineClass` with a `byte[]` and
`JniEnvironment.Strings.NewString` with a `string` own their pinning. Unsafe
operations remain inside their implementations; local scopes do not transfer
buffer ownership to callers or change JNI reference cleanup.

`JniNativeMethod` preserves the sequential layout of JNI's `JNINativeMethod`.
Its callback is stored as `void*`, since JNI callbacks have heterogeneous
signatures. Name and signature storage must survive registration. The callback
must match its JNI descriptor and remain callable and rooted until successful
unregistration **and completion of all in-flight callbacks**.

The updated rules permit pointer declarations outside unsafe type contexts and
safe explicit-layout scalar fields. Object-reference control blocks and JavaVM
arguments retain their allocation, encoding, and release behavior. JNI object
handles and method/field IDs remain opaque `IntPtr` values.

## Native caller contracts

JNI declarations distinguish caller-owned raw memory from implementation-owned
buffers. Their `<safety>` documentation describes buffer bounds, pinning, and
lifetimes. Managed alternatives such as `JniType.DefineClass` with a `byte[]` and
`JniEnvironment.Strings.NewString` with a `string` own their pinning and keep
unsafe operations inside their implementations.

For UTF-8 span lookups, callers must provide readable NUL-terminated backing
storage throughout the call. Complete C# UTF-8 literals supply a trailing NUL
outside the span length; copying or slicing a span does not preserve that
guarantee. These APIs do not add terminators or retain their input spans.

Native registration names and signatures must survive registration. Callbacks
must match their JNI descriptors and remain callable and rooted until successful
unregistration **and completion of all in-flight callbacks**, including after
failed or partial registration. Declaration-level contracts do not validate
callback ABIs or change registration ownership.

Caller-unsafe declarations are enforced for updated-rule consumers, including
pointer-free signatures whose JNI descriptors carry native ABI obligations.
Generated JNI wrappers and T4 inputs keep implementation unsafe blocks local
without changing marshaling, dispatch, or cleanup.

## Generated managed binding safety

The binding generator keeps managed method, constructor, and property contracts
safe. JNI argument storage, invocation, string conversion, array copy-back, and
reference cleanup live in local `unsafe` implementation regions. SourceWriter's
`HasUnsafeBody` and accessor-body flags are independent of `IsUnsafe`, which
continues to describe a caller obligation. Abstract and interface declarations
are not made caller-unsafe to accommodate their implementations.

These blocks use existing C# syntax so generated bindings remain usable with
legacy compilers. Generator tests retain the legacy Roslyn harness and also use
the build SDK's actual compiler with updated memory-safety rules, including
implementation/reference metadata and negative callsite checks. This preparation
does not enable those rules on Mono.Android or customer projects.

Throwable-derived bindings have a separate deferred-peer constructor taking
`JniObjectReferenceOptions.None`. The protected forwarding chain has no Java
constructor registration and creates no Java peer; the managed binding
constructor still performs the original JNI construction. Existing peer-constructor
partial hooks are preserved in the forwarding path. Raw-handle constructors
retain their caller obligations. For a referenced intermediate base, the generator
checks whether the actual assembly exposes this constructor. Older bases without
it keep legacy zero-handle initialization rather than receiving an unresolvable
constructor call. If an updated base marks its raw constructor requires-unsafe
but lacks the deferred constructor, a safe generated constructor fails the
updated-rules compilation; regenerating the base supplies the safe path. Legacy
reference metadata does not enforce the new raw caller contract.
## Naming conventions

Types such as `JavaObject` and `JavaArray<T>` provide managed wrappers for Java
Expand Down
Loading
Loading