SIGSEGV in art::JNI::GetObjectClass (dangling jobject) at fleet scale — race between AOT managed-to-native wrapper's GC-safe transition and handle invalidation
- Dominant language
- C#
- Stars
- 2.1k
- Forks
- 579
- Avg merge
- 1d 20h
- Merged PRs (30d)
- 257
Description
### Android framework version
net10.0-android
### Affected platform version
.NET 10, Android runtime pack Microsoft.Android.Runtime.Mono.36.android-arm64 36.1.30 (verified by SHA-256 of libmonodroid.so in shipped AAB); crash signature observed since at least May 2026 on earlier versions too. Devices: Samsung/Pixel/others, Android 12-16, arm64-v8a.
### Description
Production .NET MAUI app (net10.0-android, Mono runtime, full AOT with `RunAOTCompilation=true`, assembly store), ~8,000-device fleet. We collect `ApplicationExitInfo` records (incl. native-crash tombstones). One native crash signature dominates everything else:
```
signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x0 // sometimes 0x4
Crashed thread: // or ".NET TP Worker"
#00 libart.so +0x69ecf0 art::JNI::GetObjectClass(_JNIEnv*, _jobject*)
dotnet/android#1 split_config.arm64_v8a.apk +0x59f50 // earlier app build: +0x58be0
```
**Frequency: ~330 crash events from ~290 distinct devices per week (~4% of the fleet weekly, ~40-60/day in steady state).** It occurs on both the UI thread and thread-pool threads, across unrelated screens, elevated under GC pressure (e.g. heavy allocation right after app update: DB migration + full data sync) but persistent during completely normal use.
## Frame dotnet/android#1 identified — shared AOT transition wrapper, race window visible
We symbolized frame dotnet/android#1 by recompiling `Java.Interop.dll` with `mono-aot-cross` keeping symbols (the Android SDK's AOT step passes `-s` in `ld-flags`, so shipped AOT images are always stripped) and byte-matching against the stripped image from our shipped AAB (code regions are byte-identical at the same file offsets between runtime 36.1.30 and 36.1.43):
- Both offsets are inside **Mono's shared managed-to-native `native_indirect` signature wrappers in `libaot-Java.Interop.dll.so`** (`wrapper_managed_to_native ... wrapper_native_indirect_...`). `+0x59f50` is exactly the wrapper's indirect call instruction (`blr x2`) for the 2-argument signature matching `GetObjectClass(env, jobject)` — unwinders report caller frames as LR-4. Because the wrappers are shared per signature, every caller funnels through the same address — hence exactly one offset per app build across the whole fleet.
- **The race window:** the wrapper calls `mono_threads_enter_gc_safe_region_unbalanced` immediately before the indirect JNI call. The raw `jobject` was extracted from the `JniObjectReference` *before* entering GC-safe mode; a collection (and GC-bridge handle processing) running concurrently in that window can invalidate the handle, so ART dereferences a dangling `jobject` → SIGSEGV with fault addr 0x0/0x4.
- IL scan of the linked assemblies shows the managed `GetObjectClass` callers funneling into this wrapper: `JniRuntime.JniValueManager.CreatePeer`, `AndroidTypeManager/JavaTypeManager.CreateInstance` (the Java→managed peer-creation path), `JniEnvironment.Types.GetJniTypeNameFromInstance`, `Android.Runtime.JNIEnv.GetObjectClass/InvokeConstructor/AssertCompatibleArrayTypes`, `JavaConvert.GetTypeMapping`.
- Ruled out on the native side: the exact shipped `libmonodroid.so` (36.1.30) contains five `JNIEnv->GetObjectClass` call sites (init + 4 in `OSBridge`), none matching the crash offsets; `libmonosgen-2.0.so` and the crypto PAL contain none.
Possibly related: dotnet/runtime#123495 (SIGSEGV inside `sgen-tarjan-bridge.c` on .NET 10 Android), dotnet/runtime#112109.
## Related diagnosability gap
The crashing thread's backtrace always ends at frame dotnet/android#1 because **Mono's Android AOT emits no OS-readable unwind info** — no `.eh_frame`/`PT_GNU_EH_FRAME` in the AOT images, and the LLVM AOT backend likewise generates assembly without any `.cfi` directives (verified with `mono-aot-cross ... llvm`). Consequently no Mono-based Android app can symbolize past the first managed frame in `ApplicationExitInfo` / Play Console / Crashlytics native crash reports. If AOT could emit CFI (even optionally), issues like this one would arrive with full stacks.
We can attach full tombstones (all threads + memory maps) and the exact shipped `libmonodroid.so` / `libaot-*.so` binaries on request, and we can measure any candidate fix at fleet scale within days via a staged rollout.
### Steps to Reproduce
No deterministic repro — statistical at fleet scale (hundreds of events/week, ~4% of devices weekly). Elevated under GC pressure (first launch after app update: database migration + full sync), but occurs steadily during normal use on both UI and thread-pool threads. We are setting up a local repro rig (debuggable build + CheckJNI + small nursery GC stress + UI soak) and can run experimental builds.
### Did you find any workaround?
Not yet. Evaluating `MONO_GC_PARAMS=bridge-implementation=old` and the latest servicing runtime (36.1.69) as mitigations; happy to report fleet-scale telemetry for either.
### Relevant log output
```shell
```
Contributor guide
No contributing guide indexed for this repository
Research direction
Start by inspecting the shared native_indirect wrappers in libaot-Java.Interop.dll.so, especially the transition around mono_threads_enter_gc_safe_region_unbalanced and the GetObjectClass call. Trace the listed peer-creation callers, then use the proposed debuggable build with CheckJNI, GC stress, and UI soak to validate a candidate fix. Done means the crash is resolved or reproducibly narrowed, with fleet-scale telemetry available for staged validation.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- android, csharp
- Domain
- compilers, mobile-dev
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 38/100