[Android] SIGSEGV in FScopedJavaObject destructor - inline thread_local UE::Jni::Env yields invalid JNIEnv when accessed from plugin module code

Summary

Since the UE 5.8 Android JNI rework (UE::Jni namespace), FScopedJavaObject’s destructor no longer uses the JNIEnv* it was constructed with; it deletes the local ref through the new inline thread_local const UE::Jni::FEnv Env resolved at destruction time (Runtime/Core/Public/Android/AndroidJavaEnv.h:1170, engine 5.8).

When that destructor is inlined into a plugin (non-engine) module’s translation unit in a packaged monolithic Android build, the thread-local access yields an invalid JNIEnv* and the process crashes with SIGSEGV inside the inline _JNIEnv::DeleteLocalRef wrapper (jni.h:554) — i.e. while dereferencing the env’s function table, on the game thread, with a perfectly valid local ref.

The same thread-local read is fine when it happens inside engine-compiled code: AndroidJavaEnv::GetJavaEnv() (exported from Core, returns the same UE::Jni::Env) returns a valid env when called from the very same plugin functions, and all JNI made with that env — including explicit DeleteLocalRef of the same references — succeeds.

What Type of Bug are you experiencing?

Platform Mobile

Steps to Reproduce

  1. In any plugin runtime module, on the game thread of a packaged Android build, run code that creates and destroys an FScopedJavaObject — e.g.:
    JNIEnv* Env = AndroidJavaEnv::GetJavaEnv();
    // Env is valid; direct JNI calls with it succeed
    {
        auto JavaString = FJavaHelper::ToJavaString(Env, TEXT("hello"));
    } // <-- destructor: UE::Jni::Env->DeleteLocalRef(...) --> SIGSEGV
    
  2. Package for Android, deploy, launch on device.

Observed with the Sentry plugin (sentry-unreal 1.16.1), which crashed on its first FScopedJavaObject destruction during subsystem init, and — after that call site was patched to explicit ref management — on the next FScopedJavaObject destruction in plugin code. See stack traces below.

Expected Result

The destructor deletes the local reference with a valid env for the current (attached) thread, as in 5.7 and earlier where FScopedJavaObject stored and reused the JNIEnv* it was created with.

Observed Result

_JNIEnv::DeleteLocalRef                           jni.h:554
FScopedJavaObject<T>::~FScopedJavaObject          AndroidJavaEnv.h:1170
FAndroidSentrySubsystem::InitWithSettings         AndroidSentrySubsystem.cpp:116   (plugin code)
USentrySubsystem::Initialize                      SentrySubsystem.cpp
FSubsystemCollectionBase::Initialize
UEngine::Init / FEngineLoop::Init / AndroidMain

and, after removing that call site, the next scoped destruction in plugin code:

_JNIEnv::DeleteLocalRef                           jni.h:554
FScopedJavaObject<T>::~FScopedJavaObject          AndroidJavaEnv.h:1170
FSentryJavaObjectWrapper::FSentryJavaObjectWrapper AndroidSentryJavaObjectWrapper.cpp:30  (plugin code)
...
UEngine::Init / FEngineLoop::Init / AndroidMain

Affects Versions

5.8

Platform(s)

Android

For crash reports, include your callstack

SIGSEGV: Segfault
libUnreal 0x7bec7d4acc _JNIEnv::DeleteLocalRef (jni.h:554)
libUnreal 0x7bec7d4acc FScopedJavaObject::~FScopedJavaObject (AndroidJavaEnv.h:1170)
libUnreal 0x7bec7d4acc FSentryJavaObjectWrapper::FSentryJavaObjectWrapper (AndroidSentryJavaObjectWrapper.cpp:30)
libUnreal 0x7bec7d9830 FAndroidSentryConverters::VariantMapToNative (AndroidSentryConverters.cpp:118)
libUnreal 0x7bec8197c8 FAndroidSentrySubsystem::SetContext (AndroidSentrySubsystem.cpp:399)
libUnreal 0x7bec7ed4f8 USentrySubsystem::AddDefaultContext (SentrySubsystem.cpp:950)
libUnreal 0x7bec7c82e4 USentrySubsystem::Initialize (SentrySubsystem.cpp:152)
libUnreal 0x7bec7ec46c USentrySubsystem::Initialize (SentrySubsystem.cpp:62)
libUnreal 0x7be595ad90 FSubsystemCollectionBase::AddAndInitializeValidatedSubsystem
libUnreal 0x7be595dd6c FSubsystemCollectionBase::AddAndInitializeSubsystems
libUnreal 0x7be595c6b4 FSubsystemCollectionBase::Initialize
libUnreal 0x7be5c510cc UEngine::Init
libUnreal 0x7be4de2240 UGameEngine::Init
libUnreal 0x7be851b154 FEngineLoop::Init
libUnreal 0x7be8519f84 AndroidMain
libUnreal 0x7be85208d8 android_main
libUnreal 0x7be853c258 android_app_entry
libc 0x7d1a7c20ac null
libc 0x7d1a759f70 null

Additional Notes

Evidence that isolates the thread-local access

All of the following ran on the same game thread, in the same session, in plugin-compiled code:

Operation Result
AndroidJavaEnv::GetJavaEnv() (exported Core function) valid env
NewStringUTF, NewObjectV, NewGlobalRef, CallStaticVoidMethodV, … via that env all succeed
explicit Env->DeleteLocalRef(ref) via that env, incl. env re-queried from GJavaVM->GetEnv (same pointer before/after an intervening Java call) succeeds
inline FScopedJavaObject destructor (UE::Jni::Env->DeleteLocalRef(...)) SIGSEGV, 100% reproducible

The crash reproduces identically regardless of when the code runs (early engine init or deferred via ticker), ruling out initialization-order effects on the JavaVM/activity. We did not capture a register-level tombstone, so we cannot say whether the TLS lookup resolves to a wrong address or to uninitialized storage — but the access-path split above is fully reproducible.

Workaround

Avoid instantiating FScopedJavaObject (directly, or via FJavaHelper::ToJavaString / NewScopedJavaObject) in non-engine modules; use a module-local RAII type that stores the JNIEnv* obtained from AndroidJavaEnv::GetJavaEnv() and deletes through it. This fully resolves the crashes in our project.

Impact

Any plugin using the (widely documented) FScopedJavaObject / FJavaHelper patterns crashes at runtime on 5.8 Android. Public example: getsentry/sentry-unreal issue #1484.