Skip to content

Commit ffb2e8d

Browse files
authored
Fix callback and promise teardown races (#59670)
Summary: ## Context - Native callback and promise cleanup can race JavaScript VM teardown. Construction-thread identity is unsafe because execution can temporarily move to another thread. ## This diff - Keep weak native handles to callback and promise values rooted by the runtime. - Always defer implicit release; permit inline release only with explicit, current exclusive runtime access. - Release teardown roots and one-shot synchronous callbacks before their runtime-access scope ends. ## Compatibility - Native handle construction requires a dispatch context, and typed calls require that context to remain live. Teardown must clear rooted values before destroying the runtime. This builds on the investigation and reproducer contributed by HyunWoo Lee in #59579. ## Changelog: [General][Fixed] - Fix TurboModule callback and async promise cleanup racing runtime teardown [General][Deprecated] - Deprecate the legacy callback wrapper in favour of weak lifetime handles Co-authored-by: HyunWoo Lee <l2hyunwoo@gmail.com> Differential Revision: D128581452 Pull Request resolved: #59670
1 parent 4ff1019 commit ffb2e8d

25 files changed

Lines changed: 1610 additions & 139 deletions

‎packages/react-native/ReactCommon/callinvoker/ReactCommon/tests/TestCallInvoker.h‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,10 @@ class TestCallInvoker : public CallInvoker {
3030
void flushQueue()
3131
{
3232
while (!queue_.empty()) {
33-
queue_.front()(runtime_);
33+
auto job = std::move(queue_.front());
3434
queue_.pop_front();
35+
job(runtime_);
36+
job = {};
3537
runtime_.drainMicrotasks(); // Run microtasks every cycle.
3638
}
3739
}

‎packages/react-native/ReactCommon/react/bridging/CallbackWrapper.h‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,10 @@ namespace facebook::react {
1919

2020
class CallInvoker;
2121

22-
// Helper for passing jsi::Function arg to other methods.
22+
/**
23+
* @deprecated Use LongLivedObjectHandle<jsi::Function> instead.
24+
* Access and release this legacy wrapper only on the JavaScript thread.
25+
*/
2326
class CallbackWrapper : public LongLivedObject {
2427
private:
2528
CallbackWrapper(jsi::Function &&callback, jsi::Runtime &runtime, std::shared_ptr<CallInvoker> jsInvoker)

‎packages/react-native/ReactCommon/react/bridging/Function.h‎

Lines changed: 43 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
#include <react/cxxstableapi/UmbrellaGuard.h>
1111

1212
#include <react/bridging/Base.h>
13-
#include <react/bridging/CallbackWrapper.h>
13+
#include <react/bridging/CallbackWrapper.h> // Retain the legacy transitive include.
1414

1515
#include <ReactCommon/CallInvoker.h>
1616
#include <ReactCommon/SchedulerPriority.h>
@@ -61,16 +61,18 @@ class AsyncCallback {
6161

6262
void callWithArgs(std::optional<SchedulerPriority> priority, Args... args) const noexcept
6363
{
64-
if (auto wrapper = callback_->wrapper_.lock()) {
65-
auto fn = [callback = callback_,
64+
auto callback = callback_;
65+
if (auto jsInvoker = callback->function_.lockInvoker()) {
66+
// The queued function owns the tuple; its lifetime is opaque to the analyzer.
67+
// NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks)
68+
auto fn = [callback = std::move(callback),
6669
argsPtr = std::make_shared<std::tuple<Args...>>(std::make_tuple(std::forward<Args>(args)...))](
6770
jsi::Runtime &) { callback->apply(std::move(*argsPtr)); };
6871

69-
auto &jsInvoker = wrapper->jsInvoker();
7072
if (priority) {
71-
jsInvoker.invokeAsync(*priority, std::move(fn));
73+
jsInvoker->invokeAsync(*priority, std::move(fn));
7274
} else {
73-
jsInvoker.invokeAsync(std::move(fn));
75+
jsInvoker->invokeAsync(std::move(fn));
7476
}
7577
}
7678
}
@@ -79,56 +81,46 @@ class AsyncCallback {
7981
std::optional<SchedulerPriority> priority,
8082
std::function<void(jsi::Runtime &, jsi::Function &)> &&callImpl) const noexcept
8183
{
82-
if (auto wrapper = callback_->wrapper_.lock()) {
83-
// Capture callback_ and not wrapper_. If callback_ is deallocated or the
84-
// JSVM is shutdown before the async task is scheduled, the underlying
85-
// function will have been deallocated.
86-
auto fn = [callback = callback_, callImpl = std::move(callImpl)](jsi::Runtime &rt) {
87-
if (auto wrapper2 = callback->wrapper_.lock()) {
88-
callImpl(rt, wrapper2->callback());
84+
auto callback = callback_;
85+
if (auto jsInvoker = callback->function_.lockInvoker()) {
86+
// Retain native ownership until this task is destroyed, without retaining
87+
// a JavaScript value while the task is queued or discarded.
88+
auto fn = [callback = std::move(callback), callImpl = std::move(callImpl)](jsi::Runtime &) {
89+
if (!callback->function_.expired()) {
90+
auto &rt = *callback->runtime_;
91+
if (auto function = callback->function_.lockJSValue(rt)) {
92+
callImpl(rt, function->value);
93+
}
8994
}
9095
};
9196

92-
auto &jsInvoker = wrapper->jsInvoker();
9397
if (priority) {
94-
jsInvoker.invokeAsync(*priority, std::move(fn));
98+
jsInvoker->invokeAsync(*priority, std::move(fn));
9599
} else {
96-
jsInvoker.invokeAsync(std::move(fn));
100+
jsInvoker->invokeAsync(std::move(fn));
97101
}
98102
}
99103
}
100104
};
101105

102-
// You must ensure that when invoking this you're located on the JS thread, or
103-
// have exclusive control of the JS VM context. If you cannot ensure this, use
104-
// AsyncCallback instead.
106+
// Invoke only with exclusive JavaScript runtime access. Use AsyncCallback otherwise.
105107
template <typename R, typename... Args>
106108
class SyncCallback<R(Args...)> {
107109
public:
108110
SyncCallback(jsi::Runtime &rt, jsi::Function function, std::shared_ptr<CallInvoker> jsInvoker)
109-
: wrapper_(CallbackWrapper::createWeak(std::move(function), rt, std::move(jsInvoker)))
111+
: runtime_(&rt), function_(rt, std::move(function), std::move(jsInvoker))
110112
{
111113
}
112114

113-
// Disallow copying, as we can no longer safely destroy the callback
114-
// from the destructor if there's multiple copies
115115
SyncCallback(const SyncCallback &) = delete;
116116
SyncCallback &operator=(const SyncCallback &) = delete;
117+
SyncCallback(SyncCallback &&) noexcept = default;
118+
SyncCallback &operator=(SyncCallback &&) noexcept = default;
119+
~SyncCallback() = default;
117120

118-
// Allow move
119-
SyncCallback(SyncCallback &&other) noexcept : wrapper_(std::move(other.wrapper_)) {}
120-
121-
SyncCallback &operator=(SyncCallback &&other) noexcept
121+
void reset(jsi::Runtime &runtime) noexcept
122122
{
123-
wrapper_ = std::move(other.wrapper_);
124-
return *this;
125-
}
126-
127-
~SyncCallback()
128-
{
129-
if (auto wrapper = wrapper_.lock()) {
130-
wrapper->destroy();
131-
}
123+
function_.reset(runtime);
132124
}
133125

134126
R operator()(Args... args) const
@@ -138,21 +130,23 @@ class SyncCallback<R(Args...)> {
138130

139131
R call(Args... args) const
140132
{
141-
auto wrapper = wrapper_.lock();
133+
auto jsInvoker = function_.lockInvoker();
134+
auto function = jsInvoker ? function_.lockJSValue(*runtime_) : nullptr;
142135

143-
// If the wrapper has been deallocated, we can no longer provide a return
144-
// value consistently, so our only option is to throw
145-
if (!wrapper) {
136+
// If the function has been deallocated, we can no longer provide a return
137+
// value consistently, so our only option is to throw.
138+
if (!function) {
146139
if constexpr (std::is_void_v<R>) {
147140
return;
148141
} else {
149142
throw std::runtime_error("Failed to call invalidated sync callback");
150143
}
151144
}
152145

153-
auto &callback = wrapper->callback();
154-
auto &rt = wrapper->runtime();
155-
auto jsInvoker = wrapper->jsInvokerPtr();
146+
// JavaScript can destroy this native callback during the call; use only
147+
// the retained value and local runtime/invoker references afterward.
148+
auto &callback = function->value;
149+
auto &rt = *runtime_;
156150

157151
if constexpr (std::is_void_v<R>) {
158152
callback.call(rt, bridging::toJs(rt, std::forward<Args>(args), jsInvoker)...);
@@ -177,8 +171,8 @@ class SyncCallback<R(Args...)> {
177171
return call(std::move(std::get<Index>(args))...);
178172
}
179173

180-
// Held weakly so lifetime is managed by LongLivedObjectCollection.
181-
std::weak_ptr<CallbackWrapper> wrapper_;
174+
jsi::Runtime *runtime_;
175+
LongLivedObjectHandle<jsi::Function> function_;
182176
};
183177

184178
template <typename... Args>
@@ -191,7 +185,7 @@ struct Bridging<AsyncCallback<Args...>> {
191185

192186
static jsi::Function toJs(jsi::Runtime &rt, const AsyncCallback<Args...> &value)
193187
{
194-
return value.callback_->function_.getFunction(rt);
188+
return Bridging<SyncCallback<void(Args...)>>::toJs(rt, *value.callback_);
195189
}
196190
};
197191

@@ -205,7 +199,10 @@ struct Bridging<SyncCallback<R(Args...)>> {
205199

206200
static jsi::Function toJs(jsi::Runtime &rt, const SyncCallback<R(Args...)> &value)
207201
{
208-
return value.function_.getFunction(rt);
202+
if (auto function = value.function_.lockJSValue(rt)) {
203+
return function->value.getFunction(rt);
204+
}
205+
throw jsi::JSError(rt, "Failed to get invalidated callback");
209206
}
210207
};
211208

‎packages/react-native/ReactCommon/react/bridging/LongLivedObject.cpp‎

Lines changed: 45 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
*/
77

88
#include "LongLivedObject.h"
9+
10+
#include <ReactCommon/CallInvoker.h>
911
#include <unordered_map>
1012

1113
namespace facebook::react {
@@ -35,22 +37,35 @@ void LongLivedObjectCollection::add(std::shared_ptr<LongLivedObject> so) {
3537
}
3638

3739
void LongLivedObjectCollection::remove(const LongLivedObject* o) {
38-
std::scoped_lock lock(collectionMutex_);
39-
// Empty owner: lookup-only alias, with no ownership or control block.
40-
const std::shared_ptr<LongLivedObject> lookupKey{
41-
std::shared_ptr<LongLivedObject>{}, const_cast<LongLivedObject*>(o)};
42-
43-
// A shared_ptr hashes and compares by the pointer it stores, so a
44-
// non-owning shared_ptr to `o` finds its entry without walking the set.
45-
auto entry = collection_.find(lookupKey);
46-
if (entry != collection_.end()) {
47-
collection_.erase(entry);
40+
decltype(collection_)::node_type retired;
41+
{
42+
std::scoped_lock lock(collectionMutex_);
43+
// Empty owner: lookup-only alias, with no ownership or control block.
44+
const std::shared_ptr<LongLivedObject> lookupKey{
45+
std::shared_ptr<LongLivedObject>{},
46+
// Lookup never dereferences or mutates the object.
47+
// NOLINTNEXTLINE(cppcoreguidelines-pro-type-const-cast)
48+
const_cast<LongLivedObject*>(o)};
49+
50+
// A shared_ptr hashes and compares by the pointer it stores, so a
51+
// non-owning shared_ptr to `o` finds its entry without walking the set.
52+
auto entry = collection_.find(lookupKey);
53+
if (entry != collection_.end()) {
54+
retired = collection_.extract(entry);
55+
}
4856
}
4957
}
5058

5159
void LongLivedObjectCollection::clear() {
52-
std::scoped_lock lock(collectionMutex_);
53-
collection_.clear();
60+
decltype(collection_) retired;
61+
{
62+
std::scoped_lock lock(collectionMutex_);
63+
retired.swap(collection_);
64+
}
65+
}
66+
67+
void LongLivedObjectCollection::clear(jsi::Runtime& /*runtime*/) {
68+
clear();
5469
}
5570

5671
size_t LongLivedObjectCollection::size() const {
@@ -64,4 +79,22 @@ void LongLivedObject::allowRelease() {
6479
LongLivedObjectCollection::get(runtime_).remove(this);
6580
}
6681

82+
void detail::releaseLongLivedObjectOnJS(
83+
std::weak_ptr<LongLivedObject> object,
84+
std::weak_ptr<CallInvoker> jsInvoker) noexcept {
85+
if (object.expired()) {
86+
return;
87+
}
88+
if (auto invoker = jsInvoker.lock()) {
89+
invoker->invokeAsync([object = std::move(object)](jsi::Runtime&) {
90+
if (auto locked = object.lock()) {
91+
locked->allowRelease();
92+
}
93+
});
94+
}
95+
// If dispatch is dropped, runtime cleanup releases the collection. Never
96+
// fall back to acquiring or releasing a strong reference on the caller
97+
// thread.
98+
}
99+
67100
} // namespace facebook::react

0 commit comments

Comments
 (0)