Skip to content

[Due for payment 2026-05-29] [$250] Android - Native crash (SIGSEGV) in JavaTurboModule event emitter callback #90623

Closed

Description

@mountiny

Version Number: 9.3.71-0
Reproducible in staging?: Unknown
Reproducible in production?: Yes
Issue reported by: Internal
Bug source: Sentry - https://expensify.sentry.io/issues/APP-8J5
Device used: Android (arm64-v8a), confirmed on Samsung SM-S948U, Android 16
App Component: HybridApp / TurboModules

Description

The app crashes with a native SIGSEGV signal on Android. The crash originates in the React Native TurboModule event emitter infrastructure, specifically when a callback fires on a background thread and attempts to insert into eventEmitterMap_ after it may have been modified or freed.

First seen: 2026-03-27
Last seen: 2026-05-13
Scale: ~4,500 occurrences, ~3,700 users affected

Stacktrace

SIGSEGV in:

  • facebook::react::JavaTurboModule::configureEventEmitterCallback::lambda::operator()
  • std::__ndk1::unordered_map<T>::operator[]
  • std::__ndk1::__hash_table<T>::__emplace_unique_key_args<T>

The crash fires from the FileObserver thread via a JNI callback chain:
facebook::jni → JCxxCallbackImpl::invoke → std::function → JavaTurboModule::configureEventEmitterCallback

Action Performed

Unknown — no reliable repro steps. The crash appears intermittently and is not tied to a specific user action.

Expected Result

No crash.

Actual Result

App crashes with a native SIGSEGV signal on Android.

Workaround

None known.

Platforms

  • Android: App
  • Android: mWeb Chrome
  • iOS: App
  • iOS: mWeb Safari
  • iOS: mWeb Chrome
  • Windows: Chrome
  • MacOS: Chrome / Safari
Upwork Automation - Do Not Edit
  • Upwork Job URL: https://www.upwork.com/jobs/~022054832905418013875
  • Upwork Job ID: 2054832905418013875
  • Last Price Increase: 2026-05-14
Issue OwnerCurrent Issue Owner: @mallenexpensify

Activity

  1. added
    BugSomething is broken. Auto assigns a BugZero manager.
    on May 14, 2026
  2. MelvinBot commented on May 14, 2026

    @MelvinBot
    Contributor

    Issue Analysis

    Root Cause: Use-after-free and data-race bug in React Native's JavaTurboModule::configureEventEmitterCallback(). The callback lambda captures this by reference via [&], so when the C++ TurboModule object is deallocated (e.g., during navigation), the lambda still holds a dangling reference to eventEmitterMap_. A background thread invoking the callback then calls unordered_map::operator[] on freed memory → SIGSEGV. Additionally, eventEmitterMap_ has no mutex protection, so concurrent access from the JS thread (find()) and background threads (operator[]) is a data race even when the object is alive.

    Upstream fix: facebook/react-native PR #55398 (opened 2026-02-03, still open/unmerged). The fix copies the map into the lambda by value and adds null-checks.

    Recommended Fix: Create a local patch (react-native+0.83.1+036+fix-turbomodule-event-emitter-uaf.patch) applying the same change from PR #55398 to JavaTurboModule.cpp:985-989. The change is small: replace [&] capture with a by-value copy of eventEmitterMap_, and use find() + null-check instead of operator[].

    Investigation details

    Vulnerable code

    In JavaTurboModule.cpp (v0.83.1, line ~985):

    auto callback = JCxxCallbackImpl::newObjectCxxArgs([&](folly::dynamic args) {
        auto eventName = args.at(0).asString();
        auto& eventEmitter = static_cast<AsyncEventEmitter<folly::dynamic>&>(
            *eventEmitterMap_[eventName].get());
        eventEmitter.emit(args.size() > 1 ? std::move(args).at(1) : nullptr);
      });

    The [&] capture implicitly captures this by reference. When the TurboModule is destroyed, the lambda still references eventEmitterMap_ → use-after-free.

    The map has no synchronization

    In TurboModule.h:89:

    std::unordered_map<std::string, std::shared_ptr<IAsyncEventEmitter>> eventEmitterMap_;

    No mutex protects this map. The JS thread reads it via find() in TurboModule::create(), while background threads write to it via operator[] in the callback.

    Triggering modules in Expensify/App

    Several TurboModules emit events from background threads, any of which could trigger this crash:

    • ReactNativeBackgroundTaskModule.kt:24-28 — emits from BroadcastReceiver (arbitrary thread)
    • react-native-device-info — emits battery/headphone events from BroadcastReceivers
    • @react-native-community/netinfo — emits network changes from ConnectivityReceiver

    The "FileObserver" thread in the stacktrace is an Android system thread (inotify-based file watcher), not from App code.

    Why this affects Expensify/App now

    • App uses React Native 0.83.1 with New Architecture enabled (newArchEnabled=true)
    • TurboModules are the active code path (not the legacy bridge)
    • 35 existing react-native patches exist in App/patches/react-native/ but none address this bug
    • First seen 2026-03-27 — likely correlates with an RN upgrade or New Architecture enablement

    Alternative explanations considered

    • Thread-safety of AsyncEventEmitter itself: AsyncEventEmitter internally uses a mutex for its listener list, so the emitter object is safe once retrieved. The crash is specifically in the map lookup, not the emit.
    • App-specific native module bug: No custom code uses FileObserver. The crash is purely in React Native framework code.

    Next Steps: Reply with @MelvinBot implement this to create a draft PR with the patch, or @MelvinBot <your feedback> to refine this analysis.

  3. added
    ExternalAdded to denote the issue can be worked on by a contributor
    on May 14, 2026
  4. added
    Help WantedApply this label when an issue is open to proposals by contributors
    on May 14, 2026
  5. melvin-bot commented on May 14, 2026

    @melvin-bot

    Triggered auto assignment to Contributor-plus team member for initial proposal review - @dukenv0307 (External)

  6. changed the title [-]Android - Native crash (SIGSEGV) in JavaTurboModule event emitter callback[/-] [+][$250] Android - Native crash (SIGSEGV) in JavaTurboModule event emitter callback[/+] on May 14, 2026
  7. melvin-bot commented on May 14, 2026

    @melvin-bot
  8. Ltbltbltbltb commented on May 14, 2026

    @Ltbltbltbltb

    Proposal

    Please re-state the problem that we are trying to solve in this issue.

    Android HybridApp can crash natively when OldDot’s background URL/FileObserver callback fires through the TurboModule event emitter while NewDot is mounting or remounting its listener bridge.

    What is the root cause of that problem?

    The JS side notifies OldDot that the URL listener is ready from the root mount effect in src/Expensify.tsx, via HybridAppModule.onURLListenerAdded(), but the actual React Native URL listener is owned by DeepLinkHandler in src/DeepLinkHandler.tsx.

    Root-cause chain: OldDot/native receives or replays a URL -> Expensify calls HybridAppModule.onURLListenerAdded() from a parent startup effect -> OldDot’s FileObserver thread starts invoking the HybridApp TurboModule callback -> the JS listener lifecycle is actually controlled later/elsewhere by DeepLinkHandler and can be torn down/recreated with Onyx dependency changes -> native can emit during that gap, hitting JavaTurboModule::configureEventEmitterCallback / eventEmitterMap_ and crashing before any visible in-app handling occurs.

    What changes do you think we should make in order to solve the problem?

    Move the HybridApp “URL listener is ready” notification to the same lifecycle that installs the URL listener, and only fire it after Linking.addEventListener('url', ...) succeeds. Remove the early root-level call from Expensify.

    In src/Expensify.tsx, delete the HybridApp notification:

    // Remove this from the root startup effect.
    if (CONFIG.IS_HYBRID_APP) {
        HybridAppModule.onURLListenerAdded();
    }

    In src/DeepLinkHandler.tsx, after the listener is registered:

    linkingChangeListener.current = Linking.addEventListener('url', (state) => {
        const isCurrentlyAuthenticated = hasAuthToken();
        openReportFromDeepLink(state.url, allReports, isCurrentlyAuthenticated, conciergeReportID, introSelected, isSelfTourViewed, betas);
    });
    
    if (CONFIG.IS_HYBRID_APP) {
        HybridAppModule.onURLListenerAdded();
    }

    This keeps the native FileObserver bridge dormant until the JS URL event listener is actually attached, and it covers both cold-start URL replay and later URL events because both go through the same DeepLinkHandler listener lifecycle.

    Contributor Details
    Field Value
    Contributor email lucastrba@gmail.com
    Upwork Profile https://www.upwork.com/freelancers/~01d016059c6701ebaa
  9. dilshodmackbook-sketch commented on May 14, 2026

    @dilshodmackbook-sketch
    Contributor

    Proposal

    What is the root cause of that problem?

    The crash happens in React Native core, in JavaTurboModule::configureEventEmitterCallback. The JNI callback lambda that bridges native events to JS does this:

    auto callback = JCxxCallbackImpl::newObjectCxxArgs([&](folly::dynamic args) {
      auto eventName = args.at(0).asString();
      auto& eventEmitter = static_cast<AsyncEventEmitter<folly::dynamic>&>(
          *eventEmitterMap_[eventName].get());
      eventEmitter.emit(args.size() > 1 ? std::move(args).at(1) : nullptr);
    });

    node_modules/react-native/ReactCommon/react/nativemodule/core/platform/android/ReactCommon/JavaTurboModule.cpp lines 985-990 — on GitHub: https://github.com/facebook/react-native/blob/v0.83.1/packages/react-native/ReactCommon/react/nativemodule/core/platform/android/ReactCommon/JavaTurboModule.cpp#L985-L990

    The problem is eventEmitterMap_[eventName] using operator[]:

    1. It mutates the map. If eventName is not a registered emitter, operator[] inserts a default-constructed (null) std::shared_ptr into eventEmitterMap_. This write can trigger a rehash of the std::unordered_map.
    2. eventEmitterMap_ is a plain std::unordered_map with no synchronization (TurboModule.h line 89: https://github.com/facebook/react-native/blob/v0.83.1/packages/react-native/ReactCommon/react/nativemodule/core/ReactCommon/TurboModule.h#L89). The callback fires on a background thread (the FileObserver/JNI thread in the crash trace), while the JS thread can concurrently read the same map in TurboModule::create() (TurboModule.h lines 130-131: https://github.com/facebook/react-native/blob/v0.83.1/packages/react-native/ReactCommon/react/nativemodule/core/ReactCommon/TurboModule.h#L130-L131). A concurrent read + rehashing write on the same unordered_map is a data race → undefined behavior → SIGSEGV.
    3. Even without the race, if the event name is unknown, operator[] returns a null shared_ptr, .get() returns nullptr, and *nullptr + static_cast dereferences a null pointer → SIGSEGV.

    What changes do you think we should make in order to solve the problem?

    Replace the mutating operator[] with a read-only find() lookup and bail out safely when the emitter is missing or null. A find() does not insert and does not rehash, so it removes the write-side of the data race, and the null guard removes the null-deref path. This should be shipped as a new patch-package patch under patches/react-native/ (e.g. react-native+0.83.1+036+fix-turbomodule-event-emitter-crash.patch).

      auto callback = JCxxCallbackImpl::newObjectCxxArgs([&](folly::dynamic args) {
        auto eventName = args.at(0).asString();
    -   auto& eventEmitter = static_cast<AsyncEventEmitter<folly::dynamic>&>(
    -       *eventEmitterMap_[eventName].get());
    -   eventEmitter.emit(args.size() > 1 ? std::move(args).at(1) : nullptr);
    +   auto it = eventEmitterMap_.find(eventName);
    +   if (it == eventEmitterMap_.end() || it->second == nullptr) {
    +     return;
    +   }
    +   auto& eventEmitter =
    +       static_cast<AsyncEventEmitter<folly::dynamic>&>(*it->second);
    +   eventEmitter.emit(args.size() > 1 ? std::move(args).at(1) : nullptr);
      });

    This is the minimal, targeted fix. eventEmitterMap_ is only populated once at module construction, so once we stop mutating it from the callback path, the remaining accesses are read-only and the crash is eliminated.

    What alternative solutions did you explore? (Optional)

    • Guard eventEmitterMap_ with a mutex. This fully serializes all access and is the "most correct" thread-safety fix, but it touches TurboModule.h shared by every platform and adds locking on a hot path. Since the map is effectively immutable after construction, the find() + null-check fix is sufficient without the overhead.
    • Wait for an upstream React Native fix. Given ~4,500 crashes across ~3,700 users, patching locally via patch-package is the faster path; the same change can be contributed upstream in parallel.
  10. trasnake87 commented on May 14, 2026

    @trasnake87
    Contributor

    Proposal

    Android devices are crashing with a native SIGSEGV inside the React Native TurboModule infrastructure — specifically when a Java-side emitOnXxx callback fires from a background thread (the FileObserver thread, per the Sentry trace) and re-enters native code through JavaTurboModule::configureEventEmitterCallback's lambda.

    Root cause

    The crash originates in upstream React Native at node_modules/react-native/ReactCommon/react/nativemodule/core/platform/android/ReactCommon/JavaTurboModule.cpp:973-999. The lambda installed as the JNI callback uses unordered_map::operator[] to look up the emitter by name:

    // node_modules/react-native/ReactCommon/react/nativemodule/core/platform/android/ReactCommon/JavaTurboModule.cpp:973-999
    void JavaTurboModule::configureEventEmitterCallback() {
      JNIEnv* env = jni::Environment::current();
      static jmethodID cachedMethodId = nullptr;
      if (cachedMethodId == nullptr) {
        jclass cls = env->GetObjectClass(instance_.get());
        cachedMethodId = env->GetMethodID(
            cls,
            "setEventEmitterCallback",
            "(Lcom/facebook/react/bridge/CxxCallbackImpl;)V");
        FACEBOOK_JNI_THROW_PENDING_EXCEPTION();
      }
    
      auto callback = JCxxCallbackImpl::newObjectCxxArgs([&](folly::dynamic args) {
        auto eventName = args.at(0).asString();
        auto& eventEmitter = static_cast<AsyncEventEmitter<folly::dynamic>&>(
            *eventEmitterMap_[eventName].get());            // <-- crash site
        eventEmitter.emit(args.size() > 1 ? std::move(args).at(1) : nullptr);
      });
    
      jvalue args[1];
      args[0].l = callback.release();
    
      // CallVoidMethod is replaced with CallVoidMethodA as it's unsafe on 32bit and
      // causes crashes https://github.com/facebook/react-native/issues/51628
      env->CallVoidMethodA(instance_.get(), cachedMethodId, args);
      FACEBOOK_JNI_THROW_PENDING_EXCEPTION();
    }

    There are three independent defects on this one line, and the Sentry frames (std::__ndk1::unordered_map<T>::operator[] → std::__ndk1::__hash_table<T>::__emplace_unique_key_args<T>) match the failure mode produced when any of them is exercised:

    1 - operator[] mutates the map on a miss. If Java emits an event whose name is not present in eventEmitterMap_ (a typo, a stale subscription, or an event added in a newer module spec than the C++ binary knows about), operator[] default-constructs a std::shared_ptr<IAsyncEventEmitter> (which is null) and inserts it. The very next call — .get() followed by * — then dereferences a null pointer → SIGSEGV. Compare this to the JS-side lookup which already does the right thing in node_modules/react-native/ReactCommon/react/nativemodule/core/ReactCommon/TurboModule.h:130-132:

    // node_modules/react-native/ReactCommon/react/nativemodule/core/ReactCommon/TurboModule.h:130-132
    } else if (auto eventEmitterIter = eventEmitterMap_.find(propNameUtf8);
               eventEmitterIter != eventEmitterMap_.end()) {
      return eventEmitterIter->second->get(runtime, jsInvoker_);
    }

    That path uses find() and bails out on miss; only the JNI callback path uses the unsafe operator[].

    2 - The map has no synchronization, but the callback is invoked from arbitrary JNI threads. JCxxCallbackImpl::invoke (node_modules/react-native/ReactAndroid/src/main/jni/react/jni/JCallback.h:42-45) runs callback_(...) on whichever thread the Java caller is on — here the FileObserver background thread, but it could equally be a timer thread, the camera thread, or a Plaid SDK worker. eventEmitterMap_ is a plain std::unordered_map (TurboModule.h:89); a write triggered by operator[] on this thread can rehash concurrently with reads or writes on the JS thread/another emit thread, corrupting the hash table — exactly the __emplace_unique_key_args frame in the trace.

    3 - The lambda capture is [&], which implicitly captures this. Combined with the fact that the Java side keeps the CxxCallbackImpl alive independently of the C++ JavaTurboModule (the destructor at JavaTurboModule.cpp:39-58 only schedules the instance_ reset, it does not invalidate the previously-handed-out callback), a late callback after teardown dereferences a freed eventEmitterMap_. This explains the residual rate that the first two fixes alone would not catch.

    Proposed fix

    Add a new patch-package file patches/react-native/react-native+0.83.1+036+fix-turbomodule-event-emitter-crash.patch that replaces the lambda body with a find()-based lookup, captures this explicitly, and serializes the map accesses against the JS-thread mutations. The change is local to JavaTurboModule.cpp plus a std::mutex on the existing eventEmitterMap_ accesses in TurboModule.h.

    // node_modules/react-native/ReactCommon/react/nativemodule/core/ReactCommon/TurboModule.h
    // Add member next to eventEmitterMap_ (line ~89):
      friend class TurboModuleTestFixtureInternal;
      std::unordered_map<std::string, std::shared_ptr<IAsyncEventEmitter>> eventEmitterMap_;
      mutable std::mutex eventEmitterMapMutex_;
    // node_modules/react-native/ReactCommon/react/nativemodule/core/ReactCommon/TurboModule.h:130-132
    // Replace the existing find() branch in create() with a locked variant:
        } else {
          std::shared_ptr<IAsyncEventEmitter> emitter;
          {
            std::lock_guard<std::mutex> lock(eventEmitterMapMutex_);
            auto eventEmitterIter = eventEmitterMap_.find(propNameUtf8);
            if (eventEmitterIter != eventEmitterMap_.end()) {
              emitter = eventEmitterIter->second;
            }
          }
          if (emitter) {
            return emitter->get(runtime, jsInvoker_);
          }
          return jsi::Value::undefined();
        }
    // node_modules/react-native/ReactCommon/react/nativemodule/core/platform/android/ReactCommon/JavaTurboModule.cpp:985-990
    // Replace the existing lambda body:
      auto callback = JCxxCallbackImpl::newObjectCxxArgs([this](folly::dynamic args) {
        if (args.empty() || !args.at(0).isString()) {
          return;
        }
        auto eventName = args.at(0).asString();
        std::shared_ptr<IAsyncEventEmitter> emitter;
        {
          std::lock_guard<std::mutex> lock(eventEmitterMapMutex_);
          auto it = eventEmitterMap_.find(eventName);
          if (it == eventEmitterMap_.end() || !it->second) {
            LOG(WARNING) << "JavaTurboModule(" << name_
                         << "): emit for unknown event \"" << eventName << "\"";
            return;
          }
          emitter = it->second;
        }
        auto& eventEmitter =
            static_cast<AsyncEventEmitter<folly::dynamic>&>(*emitter);
        eventEmitter.emit(args.size() > 1 ? std::move(args).at(1) : nullptr);
      });

    Note the three concrete deltas vs. the existing code:

    • [&] → [this] — eliminates the implicit by-reference capture surface and makes it explicit that only the module pointer is held.
    • eventEmitterMap_[eventName] → eventEmitterMap_.find(eventName) with a null-check — never mutates the map, and turns an unknown event into a no-op + LOG(WARNING) instead of a null-deref.
    • Read happens under eventEmitterMapMutex_, and the shared_ptr is copied out before .emit() — so even if the JS thread mutates the map (or the module begins teardown) mid-call, the emitter we are about to invoke is kept alive for the duration of emit().

    Register the patch in patches/react-native/details.md next to entry 035 with:

    ### [react-native+0.83.1+036+fix-turbomodule-event-emitter-crash.patch]
    
    - Reason: Fixes a native SIGSEGV in `JavaTurboModule::configureEventEmitterCallback`'s
      JNI lambda. The lambda used `eventEmitterMap_[eventName]` (which inserts on miss and
      is not thread-safe) from arbitrary JNI threads — including the FileObserver thread.
      Replaces the lookup with `find()`, captures `this` explicitly, and serializes access
      to `eventEmitterMap_` with a `std::mutex`.
    - Upstream PR/issue: 🛑 (no upstream fix; same area was previously patched for
      https://github.com/facebook/react-native/issues/51628)
    - E/App issue: #90623
    

    Why this approach

    This is the same shape of fix Expensify has already shipped for adjacent TurboModule crashes — patch 019+fix-crash-when-deleting-expense.patch and 031+fix-hermes-sampling-profiler-disable.patch both narrow-target a single React Native file under patches/react-native/, and the comment already in JavaTurboModule.cpp:995-996 shows this exact function has been patched before (RN issue #51628). Replacing operator[] with find() mirrors the existing safe pattern in TurboModule.h:130-132, so the two map-access paths converge on the same idiom. A more invasive fix — converting eventEmitterMap_ to a folly::Synchronized or moving callbacks onto the JS thread via jsInvoker_->invokeAsync — was considered but rejected: it widens the patch surface across every TurboModule consumer, while the immediate crash (__emplace_unique_key_args on a find-able key) is fully addressed by removing the mutating lookup and protecting the read. The LOG(WARNING) line is intentional — it surfaces the offending event/module name in logcat so the underlying mis-emit (likely a Plaid/Vision Camera/HybridApp emitter firing a name not present in the C++ spec) can be tracked down without another Sentry round-trip.

  11. kamronbek04 commented on May 14, 2026

    @kamronbek04
    Contributor

    Proposal

    What is the root cause of that problem?

    The crash lives in JavaTurboModule::configureEventEmitterCallback() at JavaTurboModule.cpp:985-989
    (https://github.com/Expensify/App/blob/main/node_modules/react-native/ReactCommon/react/nativemodule/core/platform/android/ReactCommon/JavaTurboModule.cpp#L985-L989). The
    lambda installed as a JNI callback does this:

    auto callback = JCxxCallbackImpl::newObjectCxxArgs([&](folly::dynamic args) {
    auto eventName = args.at(0).asString();
    auto& eventEmitter = static_cast<AsyncEventEmitterfolly::dynamic&>(
    *eventEmitterMap_[eventName].get());
    eventEmitter.emit(args.size() > 1 ? std::move(args).at(1) : nullptr);
    });

    Three compounding defects on this one code path:

    1. [&] capture holds a dangling this. The Java side keeps the CxxCallbackImpl alive independently of the C++ JavaTurboModule. When the module is deallocated (e.g., during
      HybridApp navigation transitions), the lambda still references eventEmitterMap_ through a freed this pointer. The FileObserver background thread in the Sentry trace fires the
      callback after teardown — classic use-after-free.

    2. operator[] mutates on miss. If the event name isn't registered, operator[] default-constructs a null shared_ptr, inserts it, then .get() returns nullptr which gets
      dereferenced. The __emplace_unique_key_args frame in the Sentry trace directly matches an insert-triggered rehash. Contrast this with TurboModule::create() at
      TurboModule.h:130-131 (https://github.com/Expensify/App/blob/main/node_modules/react-native/ReactCommon/react/nativemodule/core/ReactCommon/TurboModule.h#L130-L131), which
      correctly uses find() with bounds checking for the same map.

    3. No synchronization. eventEmitterMap_ is a bare std::unordered_map (TurboModule.h:89
      (https://github.com/Expensify/App/blob/main/node_modules/react-native/ReactCommon/react/nativemodule/core/ReactCommon/TurboModule.h#L89)) with no mutex. The callback fires on
      arbitrary JNI threads (FileObserver, BroadcastReceiver for netinfo/battery events), while the JS thread reads the same map in create(). A concurrent write from operator[] +
      read from find() is a data race — undefined behavior even when the module is alive.

    What changes do you think we should make in order to solve the problem?

    Apply the same approach as upstream facebook/react-native PR #55398 (react/react-native#55398) (still unmerged) as a local patch:
    patches/react-native/react-native+0.83.1+036+fix-turbomodule-event-emitter-uaf.patch. The key insight from the upstream fix is capturing eventEmitterMap_ by value into the
    lambda — this copies the shared_ptr entries, keeping emitters alive via refcount even after the module is destroyed, and giving the lambda its own independent map snapshot
    that's never mutated.

    • auto callback = JCxxCallbackImpl::newObjectCxxArgs([&](folly::dynamic args) {
    • auto eventName = args.at(0).asString();
    • auto& eventEmitter = static_cast<AsyncEventEmitterfolly::dynamic&>(
    •   *eventEmitterMap_[eventName].get());
      
    • eventEmitter.emit(args.size() > 1 ? std::move(args).at(1) : nullptr);
    • });
    • auto callback = JCxxCallbackImpl::newObjectCxxArgs(
    • [emitterMap = eventEmitterMap_](folly::dynamic args) {
      
    •   auto eventName = args.at(0).asString();
      
    •   auto it = emitterMap.find(eventName);
      
    •   if (it == emitterMap.end() || it->second == nullptr) {
      
    •     return;
      
    •   }
      
    •   auto& eventEmitter =
      
    •       static_cast<AsyncEventEmitter<folly::dynamic>&>(*it->second);
      
    •   eventEmitter.emit(
      
    •       args.size() > 1 ? std::move(args).at(1) : nullptr);
      
    • });
      

    This single diff addresses all three defects simultaneously:

    • [emitterMap = eventEmitterMap_] — by-value capture copies the map and its shared_ptr entries into the lambda. No dangling this, no use-after-free. The refcount keeps each
      AsyncEventEmitter alive for the lambda's lifetime.
    • find() instead of operator[] — read-only lookup, never mutates emitterMap. Eliminates the rehash crash path and the null-deref path.
    • Thread safety — the lambda's emitterMap is a private copy, never written to after capture. The JS thread's reads on the original eventEmitterMap_ in create() are
      independent. No mutex needed.

    This is cleaner than adding a std::mutex to TurboModule.h because that would touch a header shared across every platform and every TurboModule consumer — widening the patch
    surface for a problem that only manifests in the Android JNI callback path. The by-value capture keeps the fix entirely within JavaTurboModule.cpp.

    Register in patches/react-native/details.md:

    [react-native+0.83.1+036+fix-turbomodule-event-emitter-uaf.patch]

    Verification: The crash is non-deterministic (FileObserver thread timing), so direct repro is unreliable. Validate by: (1) building the patched APK and running a stress test —
    rapid HybridApp OldDot↔NewDot transitions while toggling airplane mode (triggers netinfo emitter) and opening files (triggers FileObserver), (2) monitoring Sentry for APP-8J5
    regression over 48 hours on staging.

    What alternative solutions did you explore?

    App-level fix (moving onURLListenerAdded) — One proposal suggests relocating the HybridApp notification from Expensify.tsx to DeepLinkHandler.tsx. This narrows the timing
    window but doesn't fix the underlying C++ defect. The crash fires from FileObserver and BroadcastReceiver threads across multiple TurboModules (netinfo, device-info,
    background-task), not just the HybridApp URL path. An app-level change can't prevent arbitrary native modules from emitting on background threads after teardown.

    Mutex on eventEmitterMap_ — Adding std::mutex to TurboModule.h fully serializes access but modifies a cross-platform header used by every TurboModule on iOS and Android. The
    by-value capture approach achieves thread safety without any locking overhead and confines the patch to a single .cpp file, matching the minimal-surface pattern of existing RN
    patches in this repo (e.g., patches 019, 031, 032).

  12. rohit9625 commented on May 14, 2026

    @rohit9625
    Contributor

    I got the exact same crash recently when launching the app on my device 😅

  13. Abdulloh0109 commented on May 14, 2026

    @Abdulloh0109
    Contributor

    Proposal

    Please re-state the problem that we are trying to solve in this issue.

    Production Android users on the New Architecture (RN 0.83.1, HybridApp) hit a native SIGSEGV with the topmost frames in:

    • facebook::react::JavaTurboModule::configureEventEmitterCallback::lambda::operator()
    • std::__ndk1::unordered_map<T>::operator[]
    • std::__ndk1::__hash_table<T>::__emplace_unique_key_args<T>

    Triggered from a background JNI thread (the FileObserver thread is the one in the Sentry trace, but the same defect can fire from any non-main thread that emits events). ~4,500 crashes affecting ~3,700 users between 2026-03-27 and 2026-05-13. No reliable in-app repro; the crash is intermittent and orthogonal to specific user actions.

    • Expected: no native crash.
    • Actual: process abort with SIGSEGV inside the React Native TurboModule event-emitter bridge.

    What is the root cause of that problem?

    The crash is upstream in React Native 0.83.1, in node_modules/react-native/ReactCommon/react/nativemodule/core/platform/android/ReactCommon/JavaTurboModule.cpp#L985-L989. The JNI callback installed for event emission contains three compounding defects on one expression:

    auto callback = JCxxCallbackImpl::newObjectCxxArgs([&](folly::dynamic args) {
        auto eventName = args.at(0).asString();
        auto& eventEmitter = static_cast<AsyncEventEmitter<folly::dynamic>&>(
            *eventEmitterMap_[eventName].get());          // ← crash site
        eventEmitter.emit(args.size() > 1 ? std::move(args).at(1) : nullptr);
    });

    1. [&] capture holds a dangling this

    The Java side (CxxCallbackImpl) keeps the C++ lambda alive independently of the owning JavaTurboModule. When the module is deallocated (HybridApp navigation, screen teardown, app suspend), the lambda still references eventEmitterMap_ through a freed this. A background JNI thread that fires the callback after teardown reads/writes through that freed pointer — classic use-after-free.

    2. operator[] mutates the map on a miss

    unordered_map::operator[] is the wrong tool for a read. When the event name is not registered (renamed event, stale subscription, version skew between native and JS specs), it default-constructs a null std::shared_ptr<IAsyncEventEmitter> and inserts it. The very next call — .get() returning nullptr, then * and static_cast — dereferences null. The __emplace_unique_key_args frame in the Sentry trace is exactly an insert-triggered rehash.

    The JS-side lookup in the same file already does the right thing — TurboModule.h#L130-L132 uses find():

    } else if (auto eventEmitterIter = eventEmitterMap_.find(propNameUtf8);
               eventEmitterIter != eventEmitterMap_.end()) {
        return eventEmitterIter->second->get(runtime, jsInvoker_);
    }

    Only the JNI callback path uses the unsafe operator[].

    3. The map has no synchronization, but the callback runs on arbitrary JNI threads

    eventEmitterMap_ is a bare std::unordered_map at TurboModule.h#L89 — no mutex. The JS thread reads from it via find() in TurboModule::create(); the JNI callback can write to it via operator[] from any thread Java picks (FileObserver, BroadcastReceiver for netinfo/battery, BackgroundTask, MediaScanner, etc.). A concurrent find() + insert() on the same unordered_map is a data race → undefined behavior, even when the module is still alive.

    The Expensify codebase has several TurboModules that can emit from background threads — examples include ReactNativeBackgroundTaskModule.kt (BroadcastReceiver), @react-native-community/netinfo (ConnectivityReceiver), react-native-device-info (battery/headphone receivers). Any of these can trigger the path. The FileObserver thread in the trace is an Android system thread (inotify), not Expensify code.

    Upstream fix exists

    facebook/react-native PR #55398 (still unmerged at the time of writing) addresses (1) and (2) by capturing eventEmitterMap_ by value into the lambda — the shared_ptr entries copy with the map, keeping emitters alive via refcount even after the module is destroyed — and switching the lookup to find() with a null-check. Capturing by value also removes the write side of the data race (3), because the lambda's own map snapshot is never mutated.

    What changes do you think we should make in order to solve the problem?

    Apply a local React Native patch mirroring upstream PR #55398. The patch is small, well-targeted, and matches the conventions already used in patches/react-native/.

    1. Create patches/react-native/react-native+0.83.1+036+fix-turbomodule-event-emitter-uaf.patch that edits JavaTurboModule.cpp:985-989:

      - auto callback = JCxxCallbackImpl::newObjectCxxArgs([&](folly::dynamic args) {
      -   auto eventName = args.at(0).asString();
      -   auto& eventEmitter = static_cast<AsyncEventEmitter<folly::dynamic>&>(
      -       *eventEmitterMap_[eventName].get());
      -   eventEmitter.emit(args.size() > 1 ? std::move(args).at(1) : nullptr);
      - });
      + auto callback = JCxxCallbackImpl::newObjectCxxArgs(
      +     [emitterMap = eventEmitterMap_](folly::dynamic args) {
      +       auto eventName = args.at(0).asString();
      +       auto it = emitterMap.find(eventName);
      +       if (it == emitterMap.end() || it->second == nullptr) {
      +         return;
      +       }
      +       auto& eventEmitter =
      +           static_cast<AsyncEventEmitter<folly::dynamic>&>(*it->second);
      +       eventEmitter.emit(args.size() > 1 ? std::move(args).at(1) : nullptr);
      +     });

      What this fixes per defect:

      • (1) UAF: [emitterMap = eventEmitterMap_] captures by value. The lambda owns its own copy of the map; the captured shared_ptrs keep emitters alive independently of the C++ JavaTurboModule's lifetime.
      • (2) Null deref on miss: find() doesn't insert; the null-check on it->second == nullptr short-circuits any unregistered or null-entry case.
      • (3) Data race: the lambda's map snapshot is never mutated, so there's no insert/rehash contending with the JS thread's find() on the live map.
    2. Verify the patch applies via patch-package on the next npm install (already wired through package.json's postinstall).

    3. Telemetry follow-up (no code change required): confirm Sentry crash count drops to zero after the fix ships in the next Android release. The crash signature is unambiguous; expect a clean step function.

    Scope: one new patch file under patches/react-native/, ~15 lines of diff. No App-repo source changes, no API change, no Onyx schema change, no translation strings. Platform: Android (the upstream bug is in platform/android/); iOS is unaffected because it uses a different event-emitter path.

    Upstream relationship: when PR #55398 lands and we bump RN past that release, this patch can be deleted with no further work. Until then, the local patch is the only way to ship a fix.

    What alternative solutions did you explore? (Optional)

    • Wait for upstream PR Fix inline code doesn't accept bold and/or italic #55398 to merge and bump React Native. Rejected: PR has been open since 2026-02-03 with no merge date. Each day this remains unfixed translates to ~120 new crashes (per the Sentry rate). Local patches are a first-class workflow in this repo — 35 already exist.

    • Add a mutex to eventEmitterMap_ in TurboModule.h. Rejected: this fixes only defect (3), leaves the UAF (1) and the operator[]-mutates-on-miss (2) intact. The combined fix in the proposal addresses all three with a single lambda-capture change.

    • Suppress the callback path entirely (no event emission) until upstream lands. Rejected: event emission is a core TurboModule feature used by react-native-device-info, @react-native-community/netinfo, and the BackgroundTaskModule — disabling it would silently break legitimate features and likely cause cascading bugs that are harder to diagnose than the crash.

    • Add a try/catch around the lambda body. Rejected: SIGSEGV is not a C++ exception; you cannot catch a hardware signal with try/catch. The crash bypasses any user-space exception handling.

    • Pin the affected TurboModules to fire only on the main thread (Android runOnUiThread). Rejected: requires changes in every emitting native module, including third-party ones we don't own. Even then, the JNI callback can still be invoked from a non-JS thread in some configurations, so the underlying defect would survive.

    • Replace [&] with explicit captures of only eventEmitterMap_ (still by reference). Rejected: clarifies the intent but does not fix the bug — capture-by-reference still produces UAF when the module is freed, and the map is still mutated on miss.

  14. 36 remaining items

  15. added
    Awaiting PaymentAuto-added when associated PR is deployed to production
    and removed on May 25, 2026
  16. melvin-bot commented on May 28, 2026

    @melvin-bot

    Triggered auto assignment to @mallenexpensify (Awaiting Payment)

  17. melvin-bot commented on May 28, 2026

    @melvin-bot

    Payment Summary

    Upwork Job

    • Reviewer: @huult owed $250 via NewDot
    • Contributor: @war-in does not require payment (Contractor)

    BugZero Checklist (@mallenexpensify)

    • I have verified the correct assignees and roles are listed above and updated the necessary manual offers
    • I have verified that there are no duplicate or incorrect contracts on Upwork for this job (https://www.upwork.com/ab/applicants/2054832905418013875/hired)
    • I have verified the PR was not reverted
    • I have applied any discounts due to bugs/regressions introduced by this PR
    • I have paid out the Upwork contracts or cancelled the ones that are incorrect
    • I have verified the payment summary above is correct
  18. mallenexpensify commented on May 29, 2026

    @mallenexpensify
    Contributor

    Payment Summary

    Contributor+: @huult due $250 via NewDot

    ^ Test case created. Thx

  19. JmillsExpensify commented on Jun 2, 2026

    @JmillsExpensify
    Contributor

    $250 approved @huult

  20. moved this from Done to SUBISSUE in [#whatsnext] #qualityon Jul 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

Awaiting PaymentAuto-added when associated PR is deployed to productionBugSomething is broken. Auto assigns a BugZero manager.DailyKSv2ExternalAdded to denote the issue can be worked on by a contributor

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions