diff --git a/packages/react-native/ReactCommon/jsinspector-modern/HostTarget.cpp b/packages/react-native/ReactCommon/jsinspector-modern/HostTarget.cpp index 8186ac65ba22ba..4edef194b795c5 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/HostTarget.cpp +++ b/packages/react-native/ReactCommon/jsinspector-modern/HostTarget.cpp @@ -8,6 +8,7 @@ #include "HostTarget.h" #include "CdpJson.h" #include "HostAgent.h" +#include "HostTargetSessionObserver.h" #include "InspectorInterfaces.h" #include "InspectorUtilities.h" #include "InstanceTarget.h" @@ -43,7 +44,13 @@ class HostTargetSession { targetController, std::move(hostMetadata), state_, - executor) {} + executor) { + HostTargetSessionObserver::getInstance().onHostTargetSessionCreated(); + } + + ~HostTargetSession() { + HostTargetSessionObserver::getInstance().onHostTargetSessionDestroyed(); + } /** * Called by CallbackLocalConnection to send a message to this Session's diff --git a/packages/react-native/ReactCommon/jsinspector-modern/HostTargetSessionObserver.cpp b/packages/react-native/ReactCommon/jsinspector-modern/HostTargetSessionObserver.cpp new file mode 100644 index 00000000000000..3eff979d53d110 --- /dev/null +++ b/packages/react-native/ReactCommon/jsinspector-modern/HostTargetSessionObserver.cpp @@ -0,0 +1,70 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include +#include +#include + +#include "HostTargetSessionObserver.h" + +namespace facebook::react::jsinspector_modern { + +HostTargetSessionObserver& HostTargetSessionObserver::getInstance() { + static HostTargetSessionObserver instance; + return instance; +} + +// Will be called by HostTargetSession on inspector thread. +void HostTargetSessionObserver::onHostTargetSessionCreated() { + std::lock_guard lock(mutex_); + + ++activeSessionCount_; + if (activeSessionCount_ == 1) { + for (auto& subscriber : subscribers_) { + subscriber.second(true); + } + } +} + +// Will be called by HostTargetSession on inspector thread. +void HostTargetSessionObserver::onHostTargetSessionDestroyed() { + std::lock_guard lock(mutex_); + + assert( + activeSessionCount_ > 0 && "Unexpected overflow of HostTarget sessions"); + --activeSessionCount_; + if (activeSessionCount_ == 0) { + for (auto& subscriber : subscribers_) { + subscriber.second(false); + } + } +} + +// Will be called by NativeDebuggerSessionObserver on JS thread. +bool HostTargetSessionObserver::hasActiveSessions() { + std::lock_guard lock(mutex_); + + return activeSessionCount_ > 0; +} + +// Will be called by NativeDebuggerSessionObserver on JS thread. +std::function HostTargetSessionObserver::subscribe( + std::function callback) { + std::lock_guard lock(mutex_); + + auto subscriberIndex = subscriberIndex_++; + subscribers_.emplace(subscriberIndex, std::move(callback)); + + // Since HostTargetSessionObserver is a singleton, it is expected to outlive + // all potential subscribers + return [this, subscriberIndexToRemove = subscriberIndex]() { + std::lock_guard lock(mutex_); + subscribers_.erase(subscriberIndexToRemove); + }; +} + +} // namespace facebook::react::jsinspector_modern diff --git a/packages/react-native/ReactCommon/jsinspector-modern/HostTargetSessionObserver.h b/packages/react-native/ReactCommon/jsinspector-modern/HostTargetSessionObserver.h new file mode 100644 index 00000000000000..119ca595430c4c --- /dev/null +++ b/packages/react-native/ReactCommon/jsinspector-modern/HostTargetSessionObserver.h @@ -0,0 +1,51 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#pragma once + +#include +#include +#include +#include + +namespace facebook::react::jsinspector_modern { + +class HostTargetSessionObserver { + public: + static HostTargetSessionObserver& getInstance(); + + /* + * Not copyable. + */ + HostTargetSessionObserver(const HostTargetSessionObserver&) = delete; + HostTargetSessionObserver& operator=(const HostTargetSessionObserver&) = + delete; + + /* + * Not movable. + */ + HostTargetSessionObserver(HostTargetSessionObserver&&) = delete; + HostTargetSessionObserver& operator=(HostTargetSessionObserver&&) = delete; + + void onHostTargetSessionCreated(); + void onHostTargetSessionDestroyed(); + + bool hasActiveSessions(); + std::function subscribe(std::function callback); + + private: + HostTargetSessionObserver() = default; + ~HostTargetSessionObserver() = default; + + int activeSessionCount_ = 0; + std::map> subscribers_; + uint32_t subscriberIndex_ = 0; + + std::mutex mutex_; +}; + +} // namespace facebook::react::jsinspector_modern diff --git a/packages/react-native/ReactCommon/jsinspector-modern/tests/HostTargetSessionObserverTest.cpp b/packages/react-native/ReactCommon/jsinspector-modern/tests/HostTargetSessionObserverTest.cpp new file mode 100644 index 00000000000000..bae06b5c3c9ff3 --- /dev/null +++ b/packages/react-native/ReactCommon/jsinspector-modern/tests/HostTargetSessionObserverTest.cpp @@ -0,0 +1,146 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include +#include +#include + +#include +#include +#include + +#include + +#include "InspectorMocks.h" +#include "UniquePtrFactory.h" + +using namespace ::testing; + +namespace facebook::react::jsinspector_modern { + +namespace { + +class HostTargetSessionObserverTest : public Test { + folly::QueuedImmediateExecutor immediateExecutor_; + + protected: + HostTargetSessionObserverTest() { + EXPECT_CALL(runtimeTargetDelegate_, createAgentDelegate(_, _, _, _, _)) + .WillRepeatedly(runtimeAgentDelegates_.lazily_make_unique< + FrontendChannel, + SessionState&, + std::unique_ptr, + const ExecutionContextDescription&, + RuntimeExecutor>()); + } + + void connect() { + auto connection = makeConnection(); + + pageConnectionsPointers_.push_back(std::move(connection.first)); + } + + std::pair, MockRemoteConnection&> + makeConnection() { + size_t connectionIndex = remoteConnections_.objectsVended(); + auto toPage = page_->connect(remoteConnections_.make_unique()); + + // We'll always get an onDisconnect call when we tear + // down the test. Expect it in order to satisfy the strict mock. + EXPECT_CALL(*remoteConnections_[connectionIndex], onDisconnect()); + return {std::move(toPage), *remoteConnections_[connectionIndex]}; + } + + MockHostTargetDelegate hostTargetDelegate_; + + VoidExecutor inspectorExecutor_ = [this](auto callback) { + immediateExecutor_.add(callback); + }; + + std::shared_ptr page_ = + HostTarget::create(hostTargetDelegate_, inspectorExecutor_); + + MockRuntimeTargetDelegate runtimeTargetDelegate_; + + UniquePtrFactory> runtimeAgentDelegates_; + + MOCK_METHOD(void, subscriptionCallback, (bool hasActiveSession)); + + private: + UniquePtrFactory> remoteConnections_; + + protected: + std::vector> pageConnectionsPointers_; +}; +} // namespace + +TEST_F(HostTargetSessionObserverTest, HasNoActiveSessionsByDefault) { + EXPECT_FALSE(HostTargetSessionObserver::getInstance().hasActiveSessions()); +} + +TEST_F(HostTargetSessionObserverTest, HasActiveSessionOnceConnected) { + connect(); + EXPECT_TRUE(HostTargetSessionObserver::getInstance().hasActiveSessions()); +} + +TEST_F(HostTargetSessionObserverTest, HasNoActiveSessionsOnceDisconnected) { + connect(); + EXPECT_TRUE(HostTargetSessionObserver::getInstance().hasActiveSessions()); + + pageConnectionsPointers_[0]->disconnect(); + EXPECT_FALSE(HostTargetSessionObserver::getInstance().hasActiveSessions()); +} + +TEST_F(HostTargetSessionObserverTest, WorksWithMultipleConnections) { + connect(); + EXPECT_TRUE(HostTargetSessionObserver::getInstance().hasActiveSessions()); + + connect(); + EXPECT_TRUE(HostTargetSessionObserver::getInstance().hasActiveSessions()); + + pageConnectionsPointers_[0]->disconnect(); + EXPECT_TRUE(HostTargetSessionObserver::getInstance().hasActiveSessions()); + + pageConnectionsPointers_[1]->disconnect(); + EXPECT_FALSE(HostTargetSessionObserver::getInstance().hasActiveSessions()); +} + +TEST_F(HostTargetSessionObserverTest, CorrectlyNotifiesSubscribers) { + auto callback = [&](bool hasActiveSession) { + this->subscriptionCallback(hasActiveSession); + }; + auto unsubscribe = + HostTargetSessionObserver::getInstance().subscribe(callback); + + EXPECT_CALL(*this, subscriptionCallback(true)).Times(1); + connect(); + connect(); + + pageConnectionsPointers_[0]->disconnect(); + EXPECT_CALL(*this, subscriptionCallback(false)).Times(1); + pageConnectionsPointers_[1]->disconnect(); +} + +TEST_F(HostTargetSessionObserverTest, SupportsUnsubscribing) { + auto callback = [&](bool hasActiveSession) { + this->subscriptionCallback(hasActiveSession); + }; + auto unsubscribe = + HostTargetSessionObserver::getInstance().subscribe(callback); + + EXPECT_CALL(*this, subscriptionCallback(true)).Times(1); + connect(); + connect(); + + unsubscribe(); + + EXPECT_CALL(*this, subscriptionCallback(false)).Times(0); + pageConnectionsPointers_[0]->disconnect(); + pageConnectionsPointers_[1]->disconnect(); +} + +} // namespace facebook::react::jsinspector_modern diff --git a/packages/react-native/ReactCommon/react/nativemodule/debugging/NativeDebuggerSessionObserver.cpp b/packages/react-native/ReactCommon/react/nativemodule/debugging/NativeDebuggerSessionObserver.cpp new file mode 100644 index 00000000000000..e7d028d5344307 --- /dev/null +++ b/packages/react-native/ReactCommon/react/nativemodule/debugging/NativeDebuggerSessionObserver.cpp @@ -0,0 +1,41 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include "NativeDebuggerSessionObserver.h" +#include + +#include "Plugins.h" + +std::shared_ptr +NativeDebuggerSessionObserverModuleProvider( + std::shared_ptr jsInvoker) { + return std::make_shared( + std::move(jsInvoker)); +} + +namespace facebook::react { + +NativeDebuggerSessionObserver::NativeDebuggerSessionObserver( + std::shared_ptr jsInvoker) + : NativeDebuggerSessionObserverCxxSpec(std::move(jsInvoker)) {} + +bool NativeDebuggerSessionObserver::hasActiveSession( + jsi::Runtime& /*runtime*/) { + return jsinspector_modern::HostTargetSessionObserver::getInstance() + .hasActiveSessions(); +} + +std::function NativeDebuggerSessionObserver::subscribe( + jsi::Runtime& /*runtime*/, + AsyncCallback callback) { + return jsinspector_modern::HostTargetSessionObserver::getInstance().subscribe( + [callback = std::move(callback)](bool sessionStatus) { + callback(sessionStatus); + }); +} + +} // namespace facebook::react diff --git a/packages/react-native/ReactCommon/react/nativemodule/debugging/NativeDebuggerSessionObserver.h b/packages/react-native/ReactCommon/react/nativemodule/debugging/NativeDebuggerSessionObserver.h new file mode 100644 index 00000000000000..130cfe78ef77cf --- /dev/null +++ b/packages/react-native/ReactCommon/react/nativemodule/debugging/NativeDebuggerSessionObserver.h @@ -0,0 +1,26 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#pragma once + +#include + +namespace facebook::react { + +class NativeDebuggerSessionObserver + : public NativeDebuggerSessionObserverCxxSpec< + NativeDebuggerSessionObserver> { + public: + NativeDebuggerSessionObserver(std::shared_ptr jsInvoker); + + bool hasActiveSession(jsi::Runtime& runtime); + std::function subscribe( + jsi::Runtime& runtime, + AsyncCallback callback); +}; + +} // namespace facebook::react diff --git a/packages/react-native/src/private/specs/modules/NativeDebuggerSessionObserver.js b/packages/react-native/src/private/specs/modules/NativeDebuggerSessionObserver.js new file mode 100644 index 00000000000000..8dc6f366337c03 --- /dev/null +++ b/packages/react-native/src/private/specs/modules/NativeDebuggerSessionObserver.js @@ -0,0 +1,23 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + * @format + * @oncall react_native + */ + +import type {TurboModule} from '../../../../Libraries/TurboModule/RCTExport'; + +import * as TurboModuleRegistry from '../../../../Libraries/TurboModule/TurboModuleRegistry'; + +export interface Spec extends TurboModule { + +hasActiveSession: () => boolean; + +subscribe: (callback: (hasActiveSession: boolean) => void) => () => void; +} + +export default (TurboModuleRegistry.get( + 'NativeDebuggerSessionObserverCxx', +): ?Spec);