You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[Due for payment 2026-05-29] [$250] Android - Native crash (SIGSEGV) in JavaTurboModule event emitter callback #90623
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
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.
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[].
The [&] capture implicitly captures this by reference. When the TurboModule is destroyed, the lambda still references eventEmitterMap_ → use-after-free.
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:
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.
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.
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.
The crash happens in React Native core, in JavaTurboModule::configureEventEmitterCallback. The JNI callback lambda that bridges native events to JS does this:
The problem is eventEmitterMap_[eventName] using operator[]:
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.
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.
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:
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:
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):friendclassTurboModuleTestFixtureInternal;
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_);
}
returnjsi::Value::undefined();
}
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.
[&] 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.
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) {
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.
Reason: Fixes SIGSEGV in JavaTurboModule::configureEventEmitterCallback.
The JNI callback lambda captured this by reference via [&] and used
mutating operator[] on eventEmitterMap_ from arbitrary background threads.
Fix: copy the map by value into the lambda, use find() instead of operator[].
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).
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.
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():
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/.
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.
Verify the patch applies via patch-package on the next npm install (already wired through package.json's postinstall).
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.
Activity
MelvinBot commentedon May 14, 2026
Issue Analysis
Root Cause: Use-after-free and data-race bug in React Native's
JavaTurboModule::configureEventEmitterCallback(). The callback lambda capturesthisby reference via[&], so when the C++ TurboModule object is deallocated (e.g., during navigation), the lambda still holds a dangling reference toeventEmitterMap_. A background thread invoking the callback then callsunordered_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 toJavaTurboModule.cpp:985-989. The change is small: replace[&]capture with a by-value copy ofeventEmitterMap_, and usefind()+ null-check instead ofoperator[].Investigation details
Vulnerable code
In
JavaTurboModule.cpp(v0.83.1, line ~985):The
[&]capture implicitly capturesthisby reference. When the TurboModule is destroyed, the lambda still referenceseventEmitterMap_→ use-after-free.The map has no synchronization
In
TurboModule.h:89:No mutex protects this map. The JS thread reads it via
find()inTurboModule::create(), while background threads write to it viaoperator[]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 fromConnectivityReceiverThe "FileObserver" thread in the stacktrace is an Android system thread (inotify-based file watcher), not from App code.
Why this affects Expensify/App now
newArchEnabled=true)App/patches/react-native/but none address this bugAlternative explanations considered
AsyncEventEmitteritself:AsyncEventEmitterinternally 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.FileObserver. The crash is purely in React Native framework code.Next Steps: Reply with
@MelvinBot implement thisto create a draft PR with the patch, or@MelvinBot <your feedback>to refine this analysis.melvin-bot commentedon May 14, 2026
Triggered auto assignment to Contributor-plus team member for initial proposal review - @dukenv0307 (
External)[-]Android - Native crash (SIGSEGV) in JavaTurboModule event emitter callback[/-][+][$250] Android - Native crash (SIGSEGV) in JavaTurboModule event emitter callback[/+]melvin-bot commentedon May 14, 2026
Job added to Upwork: https://www.upwork.com/jobs/~022054832905418013875
Ltbltbltbltb commentedon May 14, 2026
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, viaHybridAppModule.onURLListenerAdded(), but the actual React Native URL listener is owned byDeepLinkHandlerinsrc/DeepLinkHandler.tsx.Root-cause chain: OldDot/native receives or replays a URL ->
ExpensifycallsHybridAppModule.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 byDeepLinkHandlerand can be torn down/recreated with Onyx dependency changes -> native can emit during that gap, hittingJavaTurboModule::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 fromExpensify.In
src/Expensify.tsx, delete the HybridApp notification:In
src/DeepLinkHandler.tsx, after the listener is registered: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
DeepLinkHandlerlistener lifecycle.Contributor Details
dilshodmackbook-sketch commentedon May 14, 2026
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:node_modules/react-native/ReactCommon/react/nativemodule/core/platform/android/ReactCommon/JavaTurboModule.cpplines 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-L990The problem is
eventEmitterMap_[eventName]usingoperator[]:eventNameis not a registered emitter,operator[]inserts a default-constructed (null)std::shared_ptrintoeventEmitterMap_. This write can trigger a rehash of thestd::unordered_map.eventEmitterMap_is a plainstd::unordered_mapwith no synchronization (TurboModule.hline 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 inTurboModule::create()(TurboModule.hlines 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 sameunordered_mapis a data race → undefined behavior → SIGSEGV.operator[]returns a nullshared_ptr,.get()returnsnullptr, and*nullptr+static_castdereferences 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-onlyfind()lookup and bail out safely when the emitter is missing or null. Afind()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 underpatches/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)
eventEmitterMap_with a mutex. This fully serializes all access and is the "most correct" thread-safety fix, but it touchesTurboModule.hshared by every platform and adds locking on a hot path. Since the map is effectively immutable after construction, thefind()+ null-check fix is sufficient without the overhead.trasnake87 commentedon May 14, 2026
Proposal
Android devices are crashing with a native SIGSEGV inside the React Native TurboModule infrastructure — specifically when a Java-side
emitOnXxxcallback fires from a background thread (theFileObserverthread, per the Sentry trace) and re-enters native code throughJavaTurboModule::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 usesunordered_map::operator[]to look up the emitter by name: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 ineventEmitterMap_(a typo, a stale subscription, or an event added in a newer module spec than the C++ binary knows about),operator[]default-constructs astd::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 innode_modules/react-native/ReactCommon/react/nativemodule/core/ReactCommon/TurboModule.h:130-132:That path uses
find()and bails out on miss; only the JNI callback path uses the unsafeoperator[].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) runscallback_(...)on whichever thread the Java caller is on — here theFileObserverbackground thread, but it could equally be a timer thread, the camera thread, or a Plaid SDK worker.eventEmitterMap_is a plainstd::unordered_map(TurboModule.h:89); a write triggered byoperator[]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_argsframe in the trace.3 - The lambda capture is
[&], which implicitly capturesthis. Combined with the fact that the Java side keeps theCxxCallbackImplalive independently of the C++JavaTurboModule(the destructor atJavaTurboModule.cpp:39-58only schedules theinstance_reset, it does not invalidate the previously-handed-out callback), a late callback after teardown dereferences a freedeventEmitterMap_. 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.patchthat replaces the lambda body with afind()-based lookup, capturesthisexplicitly, and serializes the map accesses against the JS-thread mutations. The change is local toJavaTurboModule.cppplus astd::mutexon the existingeventEmitterMap_accesses inTurboModule.h.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.eventEmitterMapMutex_, and theshared_ptris 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 ofemit().Register the patch in
patches/react-native/details.mdnext to entry 035 with: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.patchand031+fix-hermes-sampling-profiler-disable.patchboth narrow-target a single React Native file underpatches/react-native/, and the comment already inJavaTurboModule.cpp:995-996shows this exact function has been patched before (RN issue #51628). Replacingoperator[]withfind()mirrors the existing safe pattern inTurboModule.h:130-132, so the two map-access paths converge on the same idiom. A more invasive fix — convertingeventEmitterMap_to afolly::Synchronizedor moving callbacks onto the JS thread viajsInvoker_->invokeAsync— was considered but rejected: it widens the patch surface across every TurboModule consumer, while the immediate crash (__emplace_unique_key_argson afind-able key) is fully addressed by removing the mutating lookup and protecting the read. TheLOG(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.kamronbek04 commentedon May 14, 2026
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:
[&] 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.
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.
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.
This single diff addresses all three defects simultaneously:
AsyncEventEmitter alive for the lambda's lifetime.
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]
The JNI callback lambda captured
thisby reference via[&]and usedmutating
operator[]on eventEmitterMap_ from arbitrary background threads.Fix: copy the map by value into the lambda, use find() instead of operator[].
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).
rohit9625 commentedon May 14, 2026
I got the exact same crash recently when launching the app on my device 😅
Abdulloh0109 commentedon May 14, 2026
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
FileObserverthread 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.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:1.
[&]capture holds a danglingthisThe Java side (
CxxCallbackImpl) keeps the C++ lambda alive independently of the owningJavaTurboModule. When the module is deallocated (HybridApp navigation, screen teardown, app suspend), the lambda still referenceseventEmitterMap_through a freedthis. 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 missunordered_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 nullstd::shared_ptr<IAsyncEventEmitter>and inserts it. The very next call —.get()returningnullptr, then*andstatic_cast— dereferences null. The__emplace_unique_key_argsframe 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-L132usesfind():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 barestd::unordered_mapatTurboModule.h#L89— no mutex. The JS thread reads from it viafind()inTurboModule::create(); the JNI callback can write to it viaoperator[]from any thread Java picks (FileObserver, BroadcastReceiver for netinfo/battery, BackgroundTask, MediaScanner, etc.). A concurrentfind()+insert()on the sameunordered_mapis 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. TheFileObserverthread 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 — theshared_ptrentries copy with the map, keeping emitters alive via refcount even after the module is destroyed — and switching the lookup tofind()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/.Create
patches/react-native/react-native+0.83.1+036+fix-turbomodule-event-emitter-uaf.patchthat editsJavaTurboModule.cpp:985-989:What this fixes per defect:
[emitterMap = eventEmitterMap_]captures by value. The lambda owns its own copy of the map; the capturedshared_ptrs keep emitters alive independently of the C++JavaTurboModule's lifetime.find()doesn't insert; the null-check onit->second == nullptrshort-circuits any unregistered or null-entry case.find()on the live map.Verify the patch applies via
patch-packageon the nextnpm install(already wired throughpackage.json'spostinstall).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 inplatform/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_inTurboModule.h. Rejected: this fixes only defect (3), leaves the UAF (1) and theoperator[]-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 onlyeventEmitterMap_(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.36 remaining items
melvin-bot commentedon May 28, 2026
Triggered auto assignment to @mallenexpensify (
Awaiting Payment)melvin-bot commentedon May 28, 2026
Payment Summary
Upwork Job
BugZero Checklist (@mallenexpensify)
mallenexpensify commentedon May 29, 2026
Payment Summary
Contributor+: @huult due $250 via NewDot
^ Test case created. Thx
JmillsExpensify commentedon Jun 2, 2026
$250 approved @huult