Skip to content

Repository files navigation

expo-sharedobject-repro

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.

What is in here

  • modules/repro-shared-object - a local Expo module that holds long-lived native SharedObject instances (created once in OnCreate): a singleton behind getThing() and a pool of 256 behind getPooledThing(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 exposes forceArtGc() (a plain System.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.

Running it

npm install
npx expo run:android        # device attached; debug build, keep Metro running

Then press Start and watch logcat:

adb logcat -v threadtime

What you see

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.

Why it happens

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:

  1. ART must collect the Kotlin JavaScriptObject wrappers from earlier conversions (each holds a strong shared_ptr to the peer) before Hermes can consider the peer dead.
  2. 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.
  3. 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.

The fix, demonstrated

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.

About

MRE: expo-modules-core convertSharedObject null-derefs the registry lookup for a long-lived SharedObject (Android, SIGABRT)

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages