Repository navigation
[native] Load JNI shared libraries via NativeLibraryHelper - #13358
simonrozsival wants to merge 2 commits into
Conversation
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>
There was a problem hiding this comment.
🟡 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
|
/review |
|
✅ Android PR Reviewer completed successfully!
|
There was a problem hiding this comment.
⚠️ 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
|
@dalexsoto review |
dalexsoto
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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.


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 exportingJNI_OnLoadviajava.lang.System.loadLibrary(String)instead ofdlopen(3)so that ART invokesJNI_OnLoadwith the application'sClassLoaderbefore native symbol resolution proceeds.Because
System.loadLibrary(String)is@CallerSensitiveand callsRuntime.loadLibrary0(Reflection.getCallerClass(), libname), callingSystem.loadLibrarydirectly over JNI from a native or managed worker thread with no application Java frame on the stack causesReflection.getCallerClass()to returnnull. ART then falls back to the boot class loader (system ns), which does not know the application's APK or native library directories:To work around this, cba39dc added
MainThreadDsoLoader(src/native/common/include/runtime-base/mainthread-dso-loader.hh), which creates apipe(2), registers the read end on the main thread'sALooper, writes a wake byte, and blocks the calling thread on asem_timedwait(3)semaphore for up to 3 seconds while the main looper callback invokesSystem.loadLibraryvia JNI.However, dispatching to the main thread's
ALooperdoes not actually put an application Java frame on the stack: when theALooperfile-descriptor callback fires during normalLooper.loop()execution, the topmost Java frame isandroid.os.MessageQueue.nativePollOnce(Native Method), which belongs toBootClassLoader. As a result, cold worker-thread loads of libraries opted out of startup preloading via@(AndroidNativeLibraryNoJniPreload)still fail withUnsatisfiedLinkErrorinsideMessageQueue.nativePollOnce:In addition, if the main thread is waiting on the worker thread that triggered the load,
MainThreadDsoLoaderblocks for 3 seconds before timing out and failing.Solution
Route JNI
System.loadLibrarycalls through a Java helper method on the application-ownedmono.NativeLibraryHelperclass:When native code invokes
NativeLibraryHelper.loadLibrary(String)over JNI from any attached thread,NativeLibraryHelperis the immediate Java caller frame on that thread's stack.Reflection.getCallerClass()returnsmono.NativeLibraryHelper.class,ClassLoader.getClassLoader(fromClass)returns the application'sPathClassLoader/DelegateLastClassLoader, and ART'sJavaVMExt::LoadNativeLibraryexecutesJNI_OnLoadsynchronously on the calling thread underSetClassLoaderOverride(class_loader)while handling same-thread recursive loads and cross-threadJNI_OnLoadwaiting internally.Changes
src/java-runtime/java/mono/android/NativeLibraryHelper.java: AddloadLibrary(String)delegating toSystem.loadLibrary(String).src/java-runtime/java/mono/android/Runtime.javaandsrc/native/clr/host/host.cc: Replace the cachedjava_lang_Systemclass field withmono_NativeLibraryHelpersoDsoLoader::initcaches a global reference tomono.NativeLibraryHelperduring startup.src/native/common/include/runtime-base/system-loadlibrary-wrapper.hh: Invokemono.NativeLibraryHelper.loadLibrary(String)instead ofjava.lang.System.loadLibrary(String).src/native/common/include/runtime-base/dso-loader.hhandsrc/native/common/include/runtime-base/mainthread-dso-loader.hh: RemoveMainThreadDsoLoaderand itsALooper/pipe/semaphore/timeout machinery (259 lines), callingSystemLoadLibraryWrapper::load()directly on the current thread'sJNIEnv*.src/Xamarin.Android.Build.Tasks/Resources/proguard_*.cfg: Preservemono.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, attachedpthread, same-library and cross-library recursiveJNI_OnLoad, 8-thread contendedJNI_OnLoad,JNI_ERR/bad-version/Java-exception failures, 4-second slow initialization, and blocked-main-thread loads across bothextractNativeLibs="false"andextractNativeLibs="true":Looper.loop(), cold worker-thread andpthreadloads of@(AndroidNativeLibraryNoJniPreload)libraries that previously failed withUnsatisfiedLinkErrorinMessageQueue.nativePollOnce(or timed out after 3s when the main thread was blocked) now succeed, find app classes viaFindClassinsideJNI_OnLoad, register native methods, and return valid handles viadlopen(..., RTLD_NOLOAD).@(AndroidNativeLibraryNoJniPreload)and first loaded from a worker thread now executeJNI_OnLoadon that calling thread rather than dispatching to the main threadALooper(so any library whoseJNI_OnLoadrequires a prepared threadLooper/ALoopermust remain preloaded on the main thread).arm64-v8abinary and DEX size comparison:libnet-android.release.so:101,720B ->95,208B (-6,512 B / -6.40%;llvm-size -Atotal74,703->70,796B [-3,907 B],.text37,920->36,108B [-1,812 B],.rodata16,832->15,672B [-1,160 B]), identicalDT_NEEDEDentries, zerolibc++dependency.java_runtime_trimmable.dex:8,784B ->8,832B (+48 B).