software-mansion / software-mansion/react-native-screens

Android: possible RNSScreenRemovalListener / NativeProxy lifetime race (raw `this` capture) — three SIGSEGVs clustered in MountingCoordinator::pullTransaction

Open
#4,654 3 comments 0 reactions 1 assignee View on GitHub

@kkafar is already working on this.

Since Sep 14, 2026.

cr:missing-info cr:missing-repro cr:platform:android missing-info missing-repro platform:android platform:web
Dominant language
TypeScript
Stars
3.7k
Forks
714
Avg merge
2d 23h
Merged PRs (30d)
71

Description

Summary

We are hitting a recurring native crash on Android: the JS thread (mqt_v_js)
takes SIGSEGV at closely clustered offsets inside
MountingCoordinator::pullTransaction. Three occurrences across five days and
three separate APK installs (details below); in two of them pc is an attempted
instruction fetch from non-executable heap memory.

We are not claiming we have proven a cause. We found an ownership shape in
4.25.2 that looks unsafe, and we have three tombstones in the function that
consults it. We cannot connect the two with the evidence we have, and we would
rather ask than assert.

Concretely: we believe RNSScreenRemovalListener can be invoked after the
NativeProxy it captured by raw pointer is gone. We may be wrong about the
window, but the ownership shape looks worth a second opinion independent of our
crashes.

Three occurrences, three separate installs, closely clustered offsets

This is not a single observation. Device tombstones on one emulator show three
crashes in MountingCoordinator::pullTransaction across five days and three
different APK installs
:

when #00 #01 fault addr code
2026-09-09 pullTransaction+520 0x6e6f697463612f64 SEGV_MAPERR
2026-09-10 [anon:scudo:primary] pullTransaction+524 0x7751f2d3c8 SEGV_ACCERR
2026-09-13 [anon:scudo:primary] pullTransaction+524 0x7a7fcc7648 SEGV_ACCERR

The +520 and +524 offsets are adjacent AArch64 instruction positions and may
represent an indirect call and its return address. Confirming that requires
disassembly of the exact libreactnative.so BuildId, which we have not done. The
clustering is consistent with a common failure site, but does not establish
that all three crashes share one root cause — different corruption sources can
fail at the same virtual call.

The 09-09 fault address is textual. Interpreted as little-endian bytes, the
value 0x6e6f697463612f64 spells d/action. This strongly suggests textual data
was interpreted as an address, which is consistent with stale or corrupted object
state. It argues against a simple null pointer, but does not by itself distinguish
use-after-free from overwrite, type confusion, or another form of memory
corruption. Note also that a tombstone's fault address is not necessarily a callee
address — it can be the address used by a faulting load or store; registers and
disassembly would be needed to call it the callee pointer.

The signal

F/libc: Fatal signal 11 (SIGSEGV), code 2 (SEGV_ACCERR),
        fault addr 0x7a7fcc7648 in tid 31433 (mqt_v_js), pid 31365
Cause: trying to execute non-executable memory.
Process uptime: 13s

SEGV_ACCERR with #00 in [anon:scudo:primary] indicates an attempted
instruction fetch from non-executable heap memory, consistent with a corrupted
control-flow target such as a function pointer or vtable entry. This is not the
usual signature of a straightforward null-data dereference, which normally faults
near address zero — so javaPart_ == nullptr alone does not directly explain
these tombstones. We cannot rule out related teardown paths from the fault address
alone, given fbjni/JNI behaviour and any concurrent data race.

Backtrace (top frames; 94 total)

#00 pc 000000000001d648  [anon:scudo:primary]          <-- jumped into the heap
#01 facebook::react::MountingCoordinator::pullTransaction(bool) const+524
#02 facebook::react::FabricUIManagerBinding::schedulerDidFinishTransaction
#03 facebook::react::Scheduler::uiManagerDidFinishTransaction
#04 facebook::react::UIManager::shadowTreeDidFinishTransaction
#05 facebook::react::ShadowTree::mount
#06 facebook::react::ShadowTree::tryCommit
#07 facebook::react::ShadowTree::commit
#15 facebook::react::ShadowTreeRegistry::visit
#16 facebook::react::UIManager::completeSurface

The ownership shape we are asking about

NativeProxy.cpp constructs the listener with a lambda capturing raw this:

screenRemovalListener_ =
    std::make_shared<RNSScreenRemovalListener>([this](int tag) {
      static const auto method =
          javaPart_->getClass()->getMethod<void(jint)>("notifyScreenRemoved");
      method(javaPart_, tag);
    });

screenRemovalListener_ is a shared_ptr member of NativeProxy, and the
listener is handed to RN via coordinator->setMountingOverrideDelegate(...).

RN stores override delegates weakly and promotes before calling
(MountingCoordinator.cpp):

for (const auto& delegate : mountingOverrideDelegates_) {
  auto mountingOverrideDelegate = delegate.lock();     // <-- promotion
  auto shouldOverridePullTransaction = mountingOverrideDelegate &&
      mountingOverrideDelegate->shouldOverridePullTransaction();

That lock() is what makes this shape unsafe rather than safe: it keeps the
listener alive for the duration of the call via a temporary shared_ptr,
even if the owning NativeProxy is concurrently being destroyed. The listener
then invokes a callback holding a raw NativeProxy*; if the proxy has been
destroyed, accessing its javaPart_ member is undefined behaviour.

Being explicit about what this does and does not explain: under this
mechanism the promoted delegate keeps the listener and its std::function
alive, so the callable itself is not in a freed block. That means this
mechanism accounts for a dangling data pointer, and does not by itself
explain an instruction fetch from heap memory. We are reporting the ownership
shape because it looks wrong on inspection; we are explicitly not claiming it
is proven to be the cause of these tombstones.

There appear to be two distinct teardown windows:

  • After invalidateNative() but before destruction: this is still valid, but
    javaPart_ is null.
  • If a delegate promotion overlaps final destruction: this can dangle while the
    listener remains alive.

Separately, a concurrent javaPart_ = nullptr and a callback reading javaPart_
would itself be an unsynchronised data race unless something external serialises
them.

shouldOverridePullTransaction() does not disable the delegate after
invalidation (invocation of listenerFunction_ does still require a matching
RNSScreen removal mutation):

bool RNSScreenRemovalListener::shouldOverridePullTransaction() const {
  return true;
}

Teardown drops the Java reference without unregistering the override
(ScreensModule.kt):

override fun invalidate() {
    super.invalidate()
    proxy?.invalidateNative()   // C++: javaPart_ = nullptr
    proxy = null                // hybrid C++ NativeProxy becomes collectable
    ...
}

invalidateNative() only nulls javaPart_; it does not reset
screenRemovalListener_ and does not unregister the delegate from any coordinator
in coordinatorsWithMountingOverrides_. So after invalidateNative() the
delegate can apparently still be promoted and invoked with javaPart_ cleared.
Separately, if delegate invocation can overlap final destruction of NativeProxy,
a previously promoted listener can invoke its raw-this callback after the owner
has been destroyed.

The code above shows no synchronisation closing these windows, but we also cannot
prove the wider RN/fbjni lifecycle permits the necessary concurrency — which is
why the questions below are questions.

Environment

  • react-native-screens 4.25.2 (the version Expo SDK 56 pins in
    bundledNativeModules.json — we are on the certified version, not behind)
  • React Native 0.85.3, Fabric + Hermes, bridgeless (loadJSBundleFromMetro)
  • Expo SDK 56
  • Android emulator emu64a, API 36, arm64, 2560 MB, build BE2A.250530.026.F3
  • libreactnative.so BuildId 156a9f475237d6c326fbf9b0893d3bf9646f8e00
  • libhermesvm.so BuildId 86901869c0e5d24d54f9204156e5abc7eb9d9da9

What the logs do and don't show

These are observations, not formal exclusions:

  • No evidence of OOM — no lowmemorykiller / OutOfMemory / am_kill in logcat.
  • No abort/assertion — raw SIGSEGV, no abort message.
  • iOS unaffected in the same run — the identical flow passed on iOS 26.5 and 18.6.
  • Not the first launch — in the 09-13 occurrence, seven other Android flows passed
    immediately before it on the same emulator and the same install.

Possible trigger

In the ~140 ms before the fault an accessibility client was polling the view
hierarchy while Fabric was committing a mount transaction:

11:11:17.689 D/Maestro: View hierarchy received in 11 ms
11:11:17.708 W/QueryController: Could not detect idle state.
11:11:17.723 D/Maestro: Requesting view hierarchy
11:11:17.776 W/QueryController: Could not detect idle state.
11:11:17.781 D/Maestro: View hierarchy received in 5 ms
--------- beginning of crash
11:11:17.828 F/libc: Fatal signal 11 (SIGSEGV) ...

This trace is from the 09-13 occurrence only; we have no equivalent logcat for
09-09 or 09-10. It shows temporal proximity, not concurrency or causation — we
cannot establish that the accessibility polling overlapped any unsafe window. We
mention it because similar hierarchy queries also occur under production
accessibility services such as TalkBack.

Reproduction honesty

Recurring, but we cannot reproduce it on demand. Three occurrences in five
days (above) — yet a deliberate homogeneous repeat of the flow that crashed on
09-13, against that same pinned binary, produced none:

10 valid trials, 0 crashes, 10 attempts (no voided runs).

  • Same cold-start flow each time (force-stop → launch → drive the screen stack),
    same emulator (emu64a, API 36, 2560 MB), same install.
  • Installed-APK identity re-proven on-device (adb shell sha256sum of pm path)
    before every trial; the JS bundle served from a Metro pinned to one commit and
    asserted every trial.
  • Crashes counted by attributing Fatal signal ... (<our package>) in logcat, not
    by flow pass/fail — the original event surfaced as an assertion timeout while the
    process had already died.

Zero events in 10 trials puts the one-sided 95% upper bound on the per-trial rate
at roughly 26%. It cannot distinguish a 1.6% rate from a 10% one. Trials share
an emulator and accumulate device state, so they are not fully independent and 26%
is optimistic.

That bounds the per-run rate; it does not establish absence, and we are not
claiming a reproducer. We are reporting the ownership shape because it looks
wrong on inspection regardless of how often it fires.

Questions

  1. Is it intended that a promoted (lock()ed) RNSScreenRemovalListener may
    outlive the NativeProxy it captured by raw this?
  2. Should invalidateNative() also reset screenRemovalListener_ and/or
    unregister the delegate from the registered coordinators?
  3. Should shouldOverridePullTransaction() become conditional (false once
    invalidated) rather than unconditionally true?
  4. Is there external serialisation between ScreensModule.invalidate() and
    mount-transaction delivery that we are not seeing?

Happy to test a patch — we have a pinned rig build and a repeatable harness.

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.