Skip to content

Commit ccff70b

Browse files
meta-yaohwaymeta-codesync[bot]
authored andcommitted
EventQueueProcessor/EventTarget: SCOPE_EXIT-guard release on throwing dispatch + fix unsigned retainCount_ underflow (#56537)
Summary: **1) `EventQueueProcessor::flushEvents` — release on every exit path.** `flushEvents` retains every `EventTarget` in the batch, then for each event calls `eventPipe_` (which dispatches into JS via `UIManagerBinding::dispatchEventToJS` and can throw `jsi::JSError`), then `eventPipeConclusion_`, and finally runs a trailing loop that calls `event.eventTarget->release(runtime)`. If either pipe call throws, stack unwinding skips the release loop, leaving `strongInstanceHandle_` (a `jsi::Value` strong reference to the JS event-target object) pinned and `retainCount_` desynchronised. This change replaces the trailing release loop with a folly `SCOPE_EXIT` block immediately after the retain pass, so every retained target is released on every exit path — normal return, `eventPipe_` throw, or `eventPipeConclusion_` throw. (Earlier revisions used a hand-rolled RAII class; switched to `SCOPE_EXIT` per reviewer feedback. `folly/ScopeGuard.h` is already in the OSS RN folly subset — `RCT-Folly.podspec`, `ReactAndroid/.../folly/CMakeLists.txt`, `react-native-fantom/.../folly/CMakeLists.txt` — and is a transitive dep of `//xplat/folly:dynamic`, so no new build edges.) Behaviour-preserving on the happy path: same release ordering (after `eventPipeConclusion_`), same no-`DispatchMutex` policy for release (the `events` vector still holds the strong `shared_ptr`s), zero added allocation. **2) `EventTarget::release` — guard the unsigned decrement.** `retainCount_` is `size_t`. `retain()` early-returns when the target is disabled, but the matching `release()` always runs, so `release()` can be entered with `retainCount_ == 0`; the unconditional `--retainCount_` then wraps to `SIZE_MAX`, the `== 0` check never fires again, and `strongInstanceHandle_` is never cleared. The trailing `react_native_assert(retainCount_ >= 0)` is a tautology on an unsigned type and is removed. This change guards the decrement (`if (retainCount_ > 0) --retainCount_;`) and drops the dead assert and the now-unused `react_native_assert.h` include. Adds `EventQueueProcessorTest.releasesEventTargetsWhenDispatchThrows` (proves the strong handle is released during unwind when `eventPipe_` throws; fails on the pre-fix trailing-loop code) and `EventTargetTests.releaseWhileDisabledDoesNotCorruptRetainCount` (retain/release while disabled, then enable+retain must still acquire the handle; fails on the pre-fix unconditional decrement). Changelog: [Internal] Pull Request resolved: #56537 Reviewed By: javache Differential Revision: D100280132 fbshipit-source-id: 8c7128082d8c967a150952434a7791b62586659f
1 parent 11d894d commit ccff70b

4 files changed

Lines changed: 98 additions & 14 deletions

File tree

packages/react-native/ReactCommon/react/renderer/core/EventQueueProcessor.cpp

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
#include "EventQueueProcessor.h"
99

10+
#include <folly/ScopeGuard.h>
1011
#include <logger/react_native_log.h>
1112
#include <react/featureflags/ReactNativeFeatureFlags.h>
1213

@@ -38,6 +39,20 @@ void EventQueueProcessor::flushEvents(
3839
}
3940
}
4041

42+
// RAII guard for the matching release() pass. If event dispatch throws (a JS
43+
// exception from eventPipe_, or eventPipeConclusion_), the previous code
44+
// skipped the release loop entirely, leaking JSI strong references. The
45+
// guard destructor runs on every exit path. No DispatchMutex needed for
46+
// release: we hold a strong pointer to each EventTarget via `events`, and
47+
// release() only touches runtime-thread-confined state.
48+
SCOPE_EXIT {
49+
for (const auto& event : events) {
50+
if (event.eventTarget) {
51+
event.eventTarget->release(runtime);
52+
}
53+
}
54+
};
55+
4156
for (const auto& event : events) {
4257
auto reactPriority = ReactEventPriority::Default;
4358

@@ -111,15 +126,7 @@ void EventQueueProcessor::flushEvents(
111126
// We only run the "Conclusion" once per event group when batched.
112127
eventPipeConclusion_(runtime);
113128

114-
// No need to lock `EventEmitter::DispatchMutex()` here.
115-
// The mutex protects from a situation when the `instanceHandle` can be
116-
// deallocated during accessing, but that's impossible at this point because
117-
// we have a strong pointer to it.
118-
for (const auto& event : events) {
119-
if (event.eventTarget) {
120-
event.eventTarget->release(runtime);
121-
}
122-
}
129+
// EventTarget release happens in the SCOPE_EXIT above.
123130
}
124131

125132
void EventQueueProcessor::flushStateUpdates(

packages/react-native/ReactCommon/react/renderer/core/EventTarget.cpp

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,6 @@
77

88
#include "EventTarget.h"
99

10-
#include <react/debug/react_native_assert.h>
11-
1210
namespace facebook::react {
1311

1412
using Tag = EventTarget::Tag;
@@ -51,11 +49,16 @@ void EventTarget::release(jsi::Runtime& /*runtime*/) {
5149
// It takes it only to ensure thread-safety (if the caller has the reference,
5250
// we are on a proper thread).
5351

54-
if (--retainCount_ == 0) {
52+
// retainCount_ is unsigned. retain() early-returns when the target is
53+
// disabled but the matching release() still runs, so we can land here with
54+
// a zero count; an unconditional decrement would wrap to SIZE_MAX and the
55+
// strong handle would never be cleared again.
56+
if (retainCount_ > 0) {
57+
--retainCount_;
58+
}
59+
if (retainCount_ == 0) {
5560
strongInstanceHandle_ = jsi::Value::null();
5661
}
57-
58-
react_native_assert(retainCount_ >= 0);
5962
}
6063

6164
jsi::Value EventTarget::getInstanceHandle(jsi::Runtime& runtime) const {

packages/react-native/ReactCommon/react/renderer/core/tests/EventQueueProcessorTest.cpp

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,13 @@
1212
#include <react/renderer/core/EventPipe.h>
1313
#include <react/renderer/core/EventQueueProcessor.h>
1414
#include <react/renderer/core/EventTarget.h>
15+
#include <react/renderer/core/InstanceHandle.h>
1516
#include <react/renderer/core/ShadowNodeFamily.h>
1617
#include <react/renderer/core/StatePipe.h>
1718
#include <react/renderer/core/ValueFactoryEventPayload.h>
1819

1920
#include <memory>
21+
#include <stdexcept>
2022
#include <string_view>
2123

2224
namespace facebook::react {
@@ -157,4 +159,50 @@ TEST_F(EventQueueProcessorTest, alwaysDiscreteEvent) {
157159
EXPECT_EQ(eventPriorities_[0], ReactEventPriority::Discrete);
158160
}
159161

162+
TEST_F(EventQueueProcessorTest, releasesEventTargetsWhenDispatchThrows) {
163+
// Set up an enabled EventTarget so that flushEvents() will retain a strong
164+
// JSI reference to its instance handle.
165+
auto object = jsi::Object(*runtime_);
166+
auto instanceHandle = std::make_shared<InstanceHandle>(
167+
*runtime_, jsi::Value(*runtime_, object), 1);
168+
auto eventTarget =
169+
std::make_shared<EventTarget>(std::move(instanceHandle), 41);
170+
eventTarget->setEnabled(true);
171+
172+
// An event pipe that throws, simulating a JS exception during dispatch.
173+
auto throwingEventPipe = [](jsi::Runtime& /*runtime*/,
174+
const EventTarget* /*eventTarget*/,
175+
const std::string& /*type*/,
176+
ReactEventPriority /*priority*/,
177+
const EventPayload& /*payload*/,
178+
HighResTimeStamp /*eventTimestamp*/) {
179+
throw std::runtime_error("dispatch failed");
180+
};
181+
auto dummyEventPipeConclusion = [](jsi::Runtime& /*runtime*/) {};
182+
auto dummyStatePipe = [](const StateUpdate& /*stateUpdate*/) {};
183+
auto mockEventLogger = std::make_shared<MockEventLogger>();
184+
185+
auto processor = EventQueueProcessor(
186+
throwingEventPipe,
187+
dummyEventPipeConclusion,
188+
dummyStatePipe,
189+
mockEventLogger);
190+
191+
EXPECT_THROW(
192+
processor.flushEvents(
193+
*runtime_,
194+
{RawEvent(
195+
"onThrow",
196+
std::make_shared<ValueFactoryEventPayload>(dummyValueFactory_),
197+
eventTarget,
198+
{},
199+
RawEvent::Category::Discrete)}),
200+
std::runtime_error);
201+
202+
// The strong JSI reference acquired by retain() must have been released even
203+
// though dispatch threw, so the instance handle is no longer reachable
204+
// through the EventTarget.
205+
EXPECT_TRUE(eventTarget->getInstanceHandle(*runtime_).isNull());
206+
}
207+
160208
} // namespace facebook::react

packages/react-native/ReactCommon/react/renderer/core/tests/EventTargetTests.cpp

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,29 @@ TEST(EventTargetTests, getInstanceHandle) {
4343

4444
EXPECT_TRUE(eventTarget.getInstanceHandle(*runtime).isNull());
4545
}
46+
47+
TEST(EventTargetTests, releaseWhileDisabledDoesNotCorruptRetainCount) {
48+
// retain() is a no-op while the target is disabled, but the matching
49+
// release() still runs. release() must tolerate a zero retain count without
50+
// underflowing, so that a later enable + retain still populates the strong
51+
// instance handle.
52+
auto runtime = facebook::hermes::makeHermesRuntime();
53+
auto object = jsi::Object(*runtime);
54+
auto instanceHandle = std::make_shared<InstanceHandle>(
55+
*runtime, jsi::Value(*runtime, object), 1);
56+
57+
auto eventTarget = EventTarget(std::move(instanceHandle), 41);
58+
59+
// Disabled by default: retain is a no-op, release sees a zero count.
60+
eventTarget.retain(*runtime);
61+
eventTarget.release(*runtime);
62+
63+
// After enabling, a fresh retain must succeed in acquiring the handle.
64+
eventTarget.setEnabled(true);
65+
eventTarget.retain(*runtime);
66+
EXPECT_FALSE(eventTarget.getInstanceHandle(*runtime).isNull());
67+
68+
// And the matching release must drop it again.
69+
eventTarget.release(*runtime);
70+
EXPECT_TRUE(eventTarget.getInstanceHandle(*runtime).isNull());
71+
}

0 commit comments

Comments
 (0)