software-mansion / software-mansion/react-native-screens
Android: possible RNSScreenRemovalListener / NativeProxy lifetime race (raw `this` capture) — three SIGSEGVs clustered in MountingCoordinator::pullTransaction
@kkafar is already working on this.
Since Sep 14, 2026.
- 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:thisis still valid, but
javaPart_is null. - If a delegate promotion overlaps final destruction:
thiscan 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-screens4.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, buildBE2A.250530.026.F3 libreactnative.soBuildId156a9f475237d6c326fbf9b0893d3bf9646f8e00libhermesvm.soBuildId86901869c0e5d24d54f9204156e5abc7eb9d9da9
What the logs do and don't show
These are observations, not formal exclusions:
- No evidence of OOM — no
lowmemorykiller/OutOfMemory/am_killin 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 sha256sumofpm 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
- Is it intended that a promoted (
lock()ed)RNSScreenRemovalListenermay
outlive theNativeProxyit captured by rawthis? - Should
invalidateNative()also resetscreenRemovalListener_and/or
unregister the delegate from the registered coordinators? - Should
shouldOverridePullTransaction()become conditional (false once
invalidated) rather than unconditionallytrue? - 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
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.