Skip to content

[native] Load JNI shared libraries via NativeLibraryHelper - #13358

Open
simonrozsival wants to merge 2 commits into
mainfrom
simonrozsival-java-jni-loader
Open

simonrozsival wants to merge 2 commits into
mainfrom
simonrozsival-java-jni-loader

Conversation

@simonrozsival

Copy link
Copy Markdown
Member

Context: #10376
Context: cba39dc
Context: https://android.googlesource.com/platform/libcore/+/refs/heads/main/ojluni/src/main/java/java/lang/System.java
Context: https://android.googlesource.com/platform/libcore/+/refs/heads/main/ojluni/src/main/java/java/lang/Runtime.java
Context: https://android.googlesource.com/platform/art/+/refs/heads/main/runtime/jni/java_vm_ext.cc

Commit cba39dc introduced DsoLoader::load_jni(), which loads packaged shared libraries exporting JNI_OnLoad via java.lang.System.loadLibrary(String) instead of dlopen(3) so that ART invokes JNI_OnLoad with the application's ClassLoader before native symbol resolution proceeds.

Because System.loadLibrary(String) is @CallerSensitive and calls Runtime.loadLibrary0(Reflection.getCallerClass(), libname), calling System.loadLibrary directly over JNI from a native or managed worker thread with no application Java frame on the stack causes Reflection.getCallerClass() to return null. ART then falls back to the boot class loader (system ns), which does not know the application's APK or native library directories:

D nativeloader: Load libSystem.Security.Cryptography.Native.Android.so using system ns (caller=/system/framework/framework.jar!classes3.dex): dlopen failed: library "libSystem.Security.Cryptography.Native.Android.so" not found

To work around this, cba39dc added MainThreadDsoLoader (src/native/common/include/runtime-base/mainthread-dso-loader.hh), which creates a pipe(2), registers the read end on the main thread's ALooper, writes a wake byte, and blocks the calling thread on a sem_timedwait(3) semaphore for up to 3 seconds while the main looper callback invokes System.loadLibrary via JNI.

However, dispatching to the main thread's ALooper does not actually put an application Java frame on the stack: when the ALooper file-descriptor callback fires during normal Looper.loop() execution, the topmost Java frame is android.os.MessageQueue.nativePollOnce(Native Method), which belongs to BootClassLoader. As a result, cold worker-thread loads of libraries opted out of startup preloading via @(AndroidNativeLibraryNoJniPreload) still fail with UnsatisfiedLinkError inside MessageQueue.nativePollOnce:

java.lang.UnsatisfiedLinkError: dlopen failed: library "libjni-loader-fixture-worker.so" not found
  at java.lang.Runtime.loadLibrary0(Runtime.java:1081)
  at java.lang.Runtime.loadLibrary0(Runtime.java:1003)
  at java.lang.System.loadLibrary(System.java:1765)
  at android.os.MessageQueue.nativePollOnce(Native Method)
  at android.os.MessageQueue.next(MessageQueue.java:346)
  at android.os.Looper.loopOnce(Looper.java:189)
  at android.os.Looper.loop(Looper.java:317)

In addition, if the main thread is waiting on the worker thread that triggered the load, MainThreadDsoLoader blocks for 3 seconds before timing out and failing.

Solution

Route JNI System.loadLibrary calls through a Java helper method on the application-owned mono.NativeLibraryHelper class:

// Called through JNI so System.loadLibrary observes an application-owned Java caller.
public static void loadLibrary (String libraryName)
{
    System.loadLibrary (libraryName);
}

When native code invokes NativeLibraryHelper.loadLibrary(String) over JNI from any attached thread, NativeLibraryHelper is the immediate Java caller frame on that thread's stack. Reflection.getCallerClass() returns mono.NativeLibraryHelper.class, ClassLoader.getClassLoader(fromClass) returns the application's PathClassLoader/DelegateLastClassLoader, and ART's JavaVMExt::LoadNativeLibrary executes JNI_OnLoad synchronously on the calling thread under SetClassLoaderOverride(class_loader) while handling same-thread recursive loads and cross-thread JNI_OnLoad waiting internally.

Changes

  • src/java-runtime/java/mono/android/NativeLibraryHelper.java: Add loadLibrary(String) delegating to System.loadLibrary(String).
  • src/java-runtime/java/mono/android/Runtime.java and src/native/clr/host/host.cc: Replace the cached java_lang_System class field with mono_NativeLibraryHelper so DsoLoader::init caches a global reference to mono.NativeLibraryHelper during startup.
  • src/native/common/include/runtime-base/system-loadlibrary-wrapper.hh: Invoke mono.NativeLibraryHelper.loadLibrary(String) instead of java.lang.System.loadLibrary(String).
  • src/native/common/include/runtime-base/dso-loader.hh and src/native/common/include/runtime-base/mainthread-dso-loader.hh: Remove MainThreadDsoLoader and its ALooper/pipe/semaphore/timeout machinery (259 lines), calling SystemLoadLibraryWrapper::load() directly on the current thread's JNIEnv*.
  • src/Xamarin.Android.Build.Tasks/Resources/proguard_*.cfg: Preserve mono.NativeLibraryHelper.loadLibrary(java.lang.String) when R8 shrinking is enabled.

Validation and Size Impact

Validated on API 35 (arm64-v8a, emulator-5554) with a 21-library native JNI probe suite exercising cold main-thread, managed worker-thread, attached pthread, same-library and cross-library recursive JNI_OnLoad, 8-thread contended JNI_OnLoad, JNI_ERR/bad-version/Java-exception failures, 4-second slow initialization, and blocked-main-thread loads across both extractNativeLibs="false" and extractNativeLibs="true":

  • Under normal Looper.loop(), cold worker-thread and pthread loads of @(AndroidNativeLibraryNoJniPreload) libraries that previously failed with UnsatisfiedLinkError in MessageQueue.nativePollOnce (or timed out after 3s when the main thread was blocked) now succeed, find app classes via FindClass inside JNI_OnLoad, register native methods, and return valid handles via dlopen(..., RTLD_NOLOAD).
  • Startup preloading on the main thread is unchanged. Libraries excluded via @(AndroidNativeLibraryNoJniPreload) and first loaded from a worker thread now execute JNI_OnLoad on that calling thread rather than dispatching to the main thread ALooper (so any library whose JNI_OnLoad requires a prepared thread Looper/ALooper must remain preloaded on the main thread).
  • Uninstrumented Release arm64-v8a binary and DEX size comparison:
    • libnet-android.release.so: 101,720 B -> 95,208 B (-6,512 B / -6.40%; llvm-size -A total 74,703 -> 70,796 B [-3,907 B], .text 37,920 -> 36,108 B [-1,812 B], .rodata 16,832 -> 15,672 B [-1,160 B]), identical DT_NEEDED entries, zero libc++ dependency.
    • java_runtime_trimmable.dex: 8,784 B -> 8,832 B (+48 B).

Context: #10376
Context: cba39dc
Context: https://android.googlesource.com/platform/libcore/+/refs/heads/main/ojluni/src/main/java/java/lang/System.java
Context: https://android.googlesource.com/platform/libcore/+/refs/heads/main/ojluni/src/main/java/java/lang/Runtime.java
Context: https://android.googlesource.com/platform/art/+/refs/heads/main/runtime/jni/java_vm_ext.cc

Commit cba39dc introduced `DsoLoader::load_jni()`, which loads packaged shared libraries exporting `JNI_OnLoad` via `java.lang.System.loadLibrary(String)` instead of `dlopen(3)` so that ART invokes `JNI_OnLoad` with the application's `ClassLoader` before native symbol resolution proceeds.

Because `System.loadLibrary(String)` is `@CallerSensitive` and calls `Runtime.loadLibrary0(Reflection.getCallerClass(), libname)`, calling `System.loadLibrary` directly over JNI from a native or managed worker thread with no application Java frame on the stack causes `Reflection.getCallerClass()` to return `null`. ART then falls back to the boot class loader (`system ns`), which does not know the application's APK or native library directories:

    D nativeloader: Load libSystem.Security.Cryptography.Native.Android.so using system ns (caller=/system/framework/framework.jar!classes3.dex): dlopen failed: library "libSystem.Security.Cryptography.Native.Android.so" not found

To work around this, cba39dc added `MainThreadDsoLoader` (`src/native/common/include/runtime-base/mainthread-dso-loader.hh`), which creates a `pipe(2)`, registers the read end on the main thread's `ALooper`, writes a wake byte, and blocks the calling thread on a `sem_timedwait(3)` semaphore for up to 3 seconds while the main looper callback invokes `System.loadLibrary` via JNI.

However, dispatching to the main thread's `ALooper` does not actually put an application Java frame on the stack: when the `ALooper` file-descriptor callback fires during normal `Looper.loop()` execution, the topmost Java frame is `android.os.MessageQueue.nativePollOnce(Native Method)`, which belongs to `BootClassLoader`. As a result, cold worker-thread loads of libraries opted out of startup preloading via `@(AndroidNativeLibraryNoJniPreload)` still fail with `UnsatisfiedLinkError` inside `MessageQueue.nativePollOnce`, and block for 3 seconds before failing if the main thread is waiting on the worker.

Fix this by routing JNI `System.loadLibrary` calls through a one-line Java helper method on the application-owned `mono.NativeLibraryHelper` class:

    public static void loadLibrary (String libraryName)
    {
        System.loadLibrary (libraryName);
    }

When native code invokes `NativeLibraryHelper.loadLibrary(String)` over JNI from any attached thread, `NativeLibraryHelper` is the immediate Java caller frame on that thread's stack. `Reflection.getCallerClass()` returns `mono.NativeLibraryHelper.class`, `ClassLoader.getClassLoader(fromClass)` returns the application's `PathClassLoader`/`DelegateLastClassLoader`, and ART's `JavaVMExt::LoadNativeLibrary` executes `JNI_OnLoad` synchronously on the calling thread under `SetClassLoaderOverride(class_loader)` while handling same-thread recursive loads and cross-thread `JNI_OnLoad` waiting internally.

Changes:

  * `src/java-runtime/java/mono/android/NativeLibraryHelper.java`: Add `loadLibrary(String)` delegating to `System.loadLibrary(String)`.
  * `src/java-runtime/java/mono/android/Runtime.java` and `src/native/clr/host/host.cc`: Replace the cached `java_lang_System` class field with `mono_NativeLibraryHelper` so `DsoLoader::init` caches a global reference to `mono.NativeLibraryHelper` during startup.
  * `src/native/common/include/runtime-base/system-loadlibrary-wrapper.hh`: Invoke `mono.NativeLibraryHelper.loadLibrary(String)` instead of `java.lang.System.loadLibrary(String)`.
  * `src/native/common/include/runtime-base/dso-loader.hh` and `src/native/common/include/runtime-base/mainthread-dso-loader.hh`: Remove `MainThreadDsoLoader` and its `ALooper`/pipe/semaphore/timeout machinery (259 lines), calling `SystemLoadLibraryWrapper::load()` directly on the current thread's `JNIEnv*`.
  * `src/Xamarin.Android.Build.Tasks/Resources/proguard_*.cfg`: Preserve `mono.NativeLibraryHelper.loadLibrary(java.lang.String)` when R8 shrinking is enabled.

Behavior and size notes:

  * Startup preloading of JNI libraries on the main thread is unchanged.
  * Libraries excluded from startup preloading via `@(AndroidNativeLibraryNoJniPreload)` and first loaded from a background thread now execute `JNI_OnLoad` synchronously on that calling thread with the application `ClassLoader` rather than dispatching to the main thread `ALooper` with a 3-second timeout.
  * Uninstrumented Release `arm64-v8a` `libnet-android.release.so` shrinks by 6,512 bytes (101,720 -> 95,208 bytes, -6.40%; `.text` -1,812 bytes, `.rodata` -1,160 bytes), while `java_runtime_trimmable.dex` increases by 48 bytes (8,784 -> 8,832 bytes).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI balanced review requested due to automatic review settings October 30, 2026 16:55

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

The synchronous JNI load can deadlock during re-entrant managed P/Invoke while holding a non-recursive cache lock, and existing tests do not exercise this path.

2 open issues
What changed in this PR

This PR routes native JNI library loads through application-owned mono.NativeLibraryHelper, giving Android the application class loader even on worker threads. It replaces the main-thread dispatch workaround introduced in #10376.

Changes:

  • Add a Java helper entry point and cache its class during runtime startup.
  • Load JNI libraries synchronously on the calling thread, removing looper dispatch and its timeout.
  • Preserve the helper entry point across R8 shrinking configurations.
File Description
src/​Xamarin.Android.Build.Tasks/​Resources/​proguard_xamarin.cfg Preserve the JNI helper entry point.
src/​Xamarin.Android.Build.Tasks/​Resources/​proguard_typemap.cfg Preserve the helper with typemap shrinking.
src/​Xamarin.Android.Build.Tasks/​Resources/​proguard_typemap_coreclr.cfg Preserve the helper with CoreCLR typemap shrinking.
src/​native/​common/​include/​runtime-base/​system-loadlibrary-wrapper.hh Invoke the application-owned Java helper.
src/​native/​common/​include/​runtime-base/​mainthread-dso-loader.hh Remove looper, pipe, semaphore, and timeout machinery.
src/​native/​common/​include/​runtime-base/​dso-loader.hh Load JNI libraries directly on the calling thread.
src/​native/​clr/​host/​host.cc Initialize the loader with the helper class.
src/​java-runtime/​java/​mono/​android/​Runtime.java Expose the helper class to native startup code.
src/​java-runtime/​java/​mono/​android/​NativeLibraryHelper.java Add the single-argument System.loadLibrary wrapper.

Review effort: Balanced

@simonrozsival

Copy link
Copy Markdown
Member Author

/review

@github-actions

github-actions Bot commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

✅ Android PR Reviewer completed successfully!

Generated by Android PR Reviewer for #13358

@github-actions github-actions Bot 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

Reviewed all nine changed files in full, including the removed loader, and traced initialization, JNI environment acquisition, native-library cache callers, and R8 configuration selection. Routing through an application-owned Java frame addresses the class-loader problem directly; the global class reference, JNI method signature, exception handling, and all three keep-rule variants are consistent. Removing the looper/pipe/semaphore machinery is a substantial simplification.

Findings: the existing inline testing warning remains applicable, and this review adds one documentation suggestion. The described probe suite is useful validation, but no regression test is committed: UnknownSystemLibraryLoadsWithoutMainThreadDispatch exercises a non-JNI cache miss, while NativeLibraryJniPreload_* tests inspect generated metadata rather than executing this path. The earlier inline testing comment already specifies the needed cold-worker, blocked-main-thread, application-class-lookup regression, so I have not duplicated it. No additional correctness regression was identified in the changed code.

CI: all 42 returned GitHub checks report success, including the aggregate dotnet-android check for Azure build 1656374. Independent Azure build/timeline verification was blocked by the CLI authentication requirement, so the Azure result could not be separately confirmed. The device probe suite and binary-size measurements in the description were not independently reproduced during this review.

Generated by Android PR Reviewer for #13358 · copilot · gpt61 · 30.2 AIC · ⌖ 4.68 AIC · ⊞ 20K
Comment /review to run again

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

Reviewed all nine changed files, including the deleted comparison-base loader, native/Java initialization and consumers, JNI attachment and cleanup, cache synchronization, R8/resource integration and current-target managed/dependency equivalence. A separate final search found no additional independent blocker. The application-owned Java caller and unchanged startup preloading are respected as intended; these two concrete worker-path defects remain:

1. Own newly created JNI attachments (DsoLoader:144). A previously JNI-free managed thread can now cold-load a packaged JNI library through RuntimeEnvironment::get_jnienv(). OSBridge attaches that caller without registering thread-exit detachment. The inspected managed owners do not provide teardown for this path, and the pinned crypto JNI helper returns early for an existing environment, before its own cleanup registration. ART makes undetached native-thread exit fatal, so ending such a thread can abort the application. The old worker dispatch did not attach this caller. Track attachments created here and arrange reliable thread-exit detachment without detaching JVM-owned or already-attached threads.

2. Release the cache mutex before Java/JNI initialization (DsoLoader:144). After startup, MonodroidDl:171-180 holds the nonrecursive DSO cache mutex across loading. Synchronous JNI_OnLoad can invoke managed code which P/Invokes another uncached packaged library; that nested load reacquires the same mutex on the same thread and permanently blocks. Base main-thread reentry could already deadlock; the demonstrated regression is the worker path, whose deleted three-second dispatch timeout previously allowed the outer worker to unwind and release the lock. ART cannot release this external mutex. Keep Java loading/JNI_OnLoad outside the cache critical section, coordinate in-progress loads separately, then publish handles under the lock.

Both findings are high-confidence static source findings against the exact comparison base and actual target. No builds, tests, generators, devices or CI commands were executed; author-reported probes were not independently reproduced.

- Perform library loading outside `dso_handle_write_lock` in `MonodroidDl::monodroid_dlopen` and use atomic load/store when reading/publishing `dso->handle`, preventing a same-thread deadlock when `JNI_OnLoad` re-enters managed code and resolves another packaged P/Invoke.
- Register a `pthread_key_create` TLS destructor in `OSBridge::ensure_jnienv` when attaching an unattached thread so threads attached by native DSO loading detach cleanly on thread exit without affecting JVM-owned or previously attached threads.
- Document caller-thread `JNI_OnLoad` execution for `@(AndroidNativeLibraryNoJniPreload)` and `$(AndroidIgnoreAllJniPreload)`.
- Add `NativeLibraryLoadTests.ExemptJniLibraryLoadsOnWorkerThreadWithBlockedMainThread` covering cold worker-thread JNI loading, application class lookup in `JNI_OnLoad`, re-entrant managed P/Invoke during `JNI_OnLoad`, thread-exit detachment, and R8 shrinking across both `extractNativeLibs` modes.

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

@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 attachment-owned teardown and original worker cache-mutex deadlock are fixed. The application-owned Java caller, caller-thread execution and startup-preload policy remain accepted.

Blocker: do not publish a recursively loaded provisional handle as completed (MonodroidDl:187-194). ART returns success to the initializing thread on same-thread recursion, before the outer JNI_OnLoad result is recorded. If A's JNI_OnLoad calls managed code that P/Invokes A again, the nested load obtains the RTLD_NOLOAD handle and publishes it into dso->handle at line193. Another thread can then take the cached-handle fast path at lines162-165 without waiting for A's initialization. If the outer initializer later returns JNI_ERR or an unsupported version, that populated cache also bypasses ART's recorded failure on subsequent calls.

Track initialization ownership/state separately: allow same-thread provisional use without completed-cache publication, make other threads wait outside the global cache mutex, and publish only after the outermost initialization succeeds. The committed A-to-B reentry regression does not cover A-to-A provisional publication/concurrent access or initialization failure. This is a high-confidence defect in the replacement publication mechanism, not a renewed caller-thread objection or the fixed cache-mutex deadlock.

All15 current changed-file families, material integration and actual-target overlap were covered; a separate final search found no additional independent blocker. Review was static/read-only; no builds, tests, generators or devices were executed.

This branch has not been deployed

No deployments
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.

3 participants