Minimal reproduction of a hard SIGABRT in expo-modules-core on Android: expo::convertSharedObject
dereferences the result of JSIContext.getSharedObject(id) without a null check, but that lookup is nullable
by contract because it resolves through a weak reference to the JS peer.
No third party library is involved. The app contains one local Expo module and one screen.
modules/repro-shared-object- a local Expo module that holds long-lived nativeSharedObjectinstances (created once inOnCreate): a singleton behindgetThing()and a pool of 256 behindgetPooledThing(i). One long-lived object is enough to crash eventually; the pool only multiplies the chances per GC cycle so the repro aborts in seconds instead of hours. It also exposesforceArtGc()(a plainSystem.gc()), because two heaps have to move before the crash becomes reachable.driver.ts- the loop. It alternates a STRIKE phase (convert every pooled object to JS once, drop all references, collect the ART-side wrappers) with a REST phase (churn the JS old generation without touching the pool, so a whole Hermes mark phase can pass with every peer unreachable).App.tsx- three buttons:Pin singleton (fix),Start,Stop, plus a live log.
npm install
npx expo run:android # device attached; debug build, keep Metro running
Then press Start and watch logcat:
adb logcat -v threadtime
Within seconds (4 out of 4 runs on a Samsung SM-A366B, Android 16, three debug and one release: after 3 s, 4 s, 5 s and 11 s of driving) the process aborts on the JS thread:
F ...repro: java_vm_ext.cc:620] JNI DETECTED ERROR IN APPLICATION: field operation on NULL object: 0x0
F ...repro: java_vm_ext.cc:620] in call to GetObjectField
F libc : Fatal signal 6 (SIGABRT), code -1 (SI_QUEUE) in tid ... (mqt_v_js), ...
with the native stack pointing at the unguarded dereference:
#08 libexpo-modules-core.so facebook::jni::getHybridDataFromField(...)
#09 libexpo-modules-core.so ...HybridClass<expo::JavaScriptObject, ...>::JavaPart::cthis() const
#10 libexpo-modules-core.so expo::convertSharedObject(...)
#12 libexpo-modules-core.so expo::MethodMetadata::callSync(...)
The [stats] lines before the abort count the pairing deaths; the driver severs and re-creates roughly 700
pairings per second, and one abort takes a few thousand deaths. A distilled crash log is in
crash-excerpt.txt.
A release build aborts identically: ART's regular JNI (no CheckJNI) also rejects the null jobject
(JNI DETECTED ERROR IN APPLICATION: obj == null from GetObjectField -> JniAbort), so this is a
guaranteed SIGABRT in both build types.
android/src/main/cpp/JNIUtils.cpp:
jsi::Value convertSharedObject(
jni::local_ref<JSharedObject::javaobject> sharedObject,
jsi::Runtime &rt,
JSIContext *jsiContext
) {
int id = sharedObject->getId();
if (id != 0) {
return jsi::Value(rt, *jsiContext->getSharedObject(id)->cthis()->get());
// ^ nullable, never checked
}
...JSIContext.getSharedObject(id) is a JavaScriptObject?, and it ends in
SharedObjectRegistry.toJavaScriptObjectOrNull:
internal fun toJavaScriptObjectOrNull(native: SharedObject): JavaScriptObject? {
return synchronized(this) {
pairs[native.sharedObjectId]?.second?.lock() // second is a JavaScriptWeakObject
}
}lock() returns null once the JS peer has been collected, and C++ then calls ->cthis() on null.
The native id is only reset to 0 by SharedObjectRegistry.delete(id), which runs from the JS peer's
NativeState destructor (NativeState::~NativeState { releaser(objectId); }). Hermes runs that destructor
when the background sweep reaches the peer's segment, while JS keeps executing. Weak references are cleared
earlier, at the end of marking. Between the two, the pairing is half dead: the id still looks live, but
nothing locks.
This is only reachable for a native SharedObject that outlives its JS peer and is handed back to JS again
later, which means a long-lived native object behind a synchronous getter. A shared object created per
call (a file handle, a player) has id 0 on every conversion and never takes the branch.
Three timing conditions have to line up, which is why the wild crash is rare and this driver forces all three:
- ART must collect the Kotlin
JavaScriptObjectwrappers from earlier conversions (each holds a strongshared_ptrto the peer) before Hermes can consider the peer dead. - Hermes weak references have a read barrier: a conversion during the mark phase resurrects the peer for that cycle. The peer only dies in an old-generation cycle whose entire mark phase saw no conversion of it. This is what the REST phase provides.
- The next conversion must land between the end of marking and the moment the background sweep runs the peer's destructor. This is what the STRIKE phase races, once per pooled object.
Press Pin singleton (fix) before Start. That holds a strong JS reference to every long-lived object
at module scope (driver.ts):
let pinned: Thing[] | null = null;
pinned = [ReproSharedObject.getThing(), /* ...and one per pooled object */];The JS peers can then never be collected, so the weak references always lock and the null branch is unreachable. Pinned, the same driver runs indefinitely (30 minutes and counting in testing, several hundred times the unpinned mean time to crash) with zero pairing deaths.