diff --git a/packages/dev-middleware/src/inspector-proxy/__docs__/README.md b/packages/dev-middleware/src/inspector-proxy/__docs__/README.md index 7a6d5f893855ee..bbd93c77f55a1c 100644 --- a/packages/dev-middleware/src/inspector-proxy/__docs__/README.md +++ b/packages/dev-middleware/src/inspector-proxy/__docs__/README.md @@ -101,20 +101,20 @@ interface Message { #### Proxy → Device Messages -| Event | Payload | Description | -| -------------- | ------------------------------------------ | --------------------------------------------- | -| `getPages` | _(none)_ | Request current page list. Sent periodically. | -| `connect` | `{ pageId: string }` | Prepare for debugger connection to page. | -| `disconnect` | `{ pageId: string }` | Terminate debugger session for page. | -| `wrappedEvent` | `{ pageId: string, wrappedEvent: string }` | Forward CDP message (JSON string) to page. | +| Event | Payload | Description | +| -------------- | ------------------------------------------------------------- | --------------------------------------------- | +| `getPages` | _(none)_ | Request current page list. Sent periodically. | +| `connect` | `{ pageId: string, sessionId: string }` | Prepare for debugger connection to page. | +| `disconnect` | `{ pageId: string, sessionId: string }` | Terminate debugger session for page. | +| `wrappedEvent` | `{ pageId: string, sessionId: string, wrappedEvent: string }` | Forward CDP message (JSON string) to page. | #### Device → Proxy Messages -| Event | Payload | Description | -| -------------- | ------------------------------------------ | ----------------------------------------------------- | -| `getPages` | `Page[]` | Current list of inspectable pages. | -| `disconnect` | `{ pageId: string }` | Notify that page disconnected or rejected connection. | -| `wrappedEvent` | `{ pageId: string, wrappedEvent: string }` | Forward CDP message (JSON string) from page. | +| Event | Payload | Description | +| -------------- | -------------------------------------------------------------- | ----------------------------------------------------- | +| `getPages` | `Page[]` | Current list of inspectable pages. | +| `disconnect` | `{ pageId: string, sessionId?: string }` | Notify that page disconnected or rejected connection. | +| `wrappedEvent` | `{ pageId: string, sessionId?: string, wrappedEvent: string }` | Forward CDP message (JSON string) from page. | #### Page Object @@ -128,10 +128,14 @@ interface Page { nativePageReloads?: boolean; // Target keeps the socket open across reloads nativeSourceCodeFetching?: boolean; // Target supports Network.loadNetworkResource prefersFuseboxFrontend?: boolean; // Target is designed for React Native DevTools + supportsMultipleDebuggers?: boolean; // Supports concurrent debugger sessions }; } ``` +**Note**: The value of `supportsMultipleDebuggers` SHOULD be consistent across +all pages for a given device. + ### Connection Lifecycle **Device Registration:** @@ -155,16 +159,18 @@ Debugger Proxy Device │ │ │ │── WS Connect ───▶│ │ │ ?device&page │── connect ────────────────▶│ - │ │ {pageId} │ + │ │ {pageId, sessionId} │ │ │ │ │── CDP Request ──▶│── wrappedEvent ───────────▶│ - │ │ {pageId, wrappedEvent} │ + │ │ {pageId, sessionId, │ + │ │ wrappedEvent} │ │ │ │ │ │◀── wrappedEvent ───────────│ - │◀── CDP Response ─│ {pageId, wrappedEvent} │ + │◀── CDP Response ─│ {pageId, sessionId, │ + │ │ wrappedEvent} │ │ │ │ │── WS Close ─────▶│── disconnect ─────────────▶│ - │ │ {pageId} │ + │ │ {pageId, sessionId} │ ``` **Connection Rejection:** @@ -174,12 +180,34 @@ a `disconnect` back to the proxy for that `pageId`. ### Connection Semantics -1. **One Debugger Per Page**: New debugger connections to an already-connected - page disconnect the existing debugger. +#### Multi-Debugger Support + +Multiple debuggers can connect simultaneously to the same page when **both** the +proxy and device support session multiplexing: + +1. **Session IDs**: The proxy assigns a unique, non-empty `sessionId` to each + debugger connection. All messages include this `sessionId` for routing. This + SHOULD be a UUID or other suitably unique and ephemeral identifier. + +2. **Capability Detection**: Devices report `supportsMultipleDebuggers: true` in + their page capabilities to indicate session support. + +3. **Backwards Compatibility**: Legacy devices ignore `sessionId` fields in + incoming messages and don't include them in responses. + +#### Connection Rules + +1. **Session-Capable Device**: Multiple debuggers can connect to the same page + simultaneously. Each connection has an independent session. + +2. **Legacy Device (no `supportsMultipleDebuggers`)**: New debugger connections + to an already-connected page disconnect the existing debugger. The proxy MUST + NOT allow multiple debuggers to connect to the same page. -2. **Device Reconnection**: If a device reconnects with the same `device` ID, +3. **Device Reconnection**: If a device reconnects with the same `device` ID + while debugger connections to the same logical device are open in the proxy, the proxy may attempt to preserve active debugger sessions by forwarding them - to the new device connection. + to the new device. ### WebSocket Close Reasons diff --git a/packages/react-native/ReactCommon/jsinspector-modern/InspectorPackagerConnection.cpp b/packages/react-native/ReactCommon/jsinspector-modern/InspectorPackagerConnection.cpp index 025782f10b141a..e2ea5b908bf575 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/InspectorPackagerConnection.cpp +++ b/packages/react-native/ReactCommon/jsinspector-modern/InspectorPackagerConnection.cpp @@ -72,29 +72,78 @@ void InspectorPackagerConnection::Impl::handleProxyMessage( void InspectorPackagerConnection::Impl::sendEventToAllConnections( const std::string& event) { - for (auto& connection : inspectorSessions_) { - connection.second.localConnection->sendMessage(event); + for (auto& pageEntry : inspectorSessionsByPage_) { + for (auto& sessionEntry : pageEntry.second) { + sessionEntry.second.localConnection->sendMessage(event); + } } } void InspectorPackagerConnection::Impl::closeAllConnections() { - for (auto& connection : inspectorSessions_) { - connection.second.localConnection->disconnect(); + while (!inspectorSessionsByPage_.empty()) { + auto pageIt = inspectorSessionsByPage_.begin(); + while (pageIt != inspectorSessionsByPage_.end() && + !pageIt->second.empty()) { + pageIt = disconnectSession({ + pageIt, + pageIt->second.begin(), + }); + } } - inspectorSessions_.clear(); +} + +std::optional> +InspectorPackagerConnection::Impl::findPageAndSession( + const folly::const_dynamic_view& payload, + std::string_view caller) { + std::string pageId = payload.descend("pageId").string_or(INVALID); + auto proxySessionId = payload.descend("sessionId").string_or(""); + + auto pageIt = inspectorSessionsByPage_.find(pageId); + if (pageIt == inspectorSessionsByPage_.end()) { + LOG(WARNING) << caller << ": page not found (pageId=" << pageId << ")"; + return std::nullopt; + } + + auto& pageSessions = pageIt->second; + auto sessionIt = pageSessions.find(proxySessionId); + if (sessionIt == pageSessions.end()) { + LOG(WARNING) << caller << ": session not found (pageId=" << pageId + << ", sessionId=" << proxySessionId << ")"; + return std::nullopt; + } + + return std::make_pair(pageIt, sessionIt); } void InspectorPackagerConnection::Impl::handleConnect( const folly::const_dynamic_view& payload) { std::string pageId = payload.descend("pageId").string_or(INVALID); - auto existingConnectionIt = inspectorSessions_.find(pageId); - if (existingConnectionIt != inspectorSessions_.end()) { - auto existingConnection = std::move(existingConnectionIt->second); - inspectorSessions_.erase(existingConnectionIt); - existingConnection.localConnection->disconnect(); - LOG(WARNING) << "Already connected: " << pageId; + auto proxySessionId = payload.descend("sessionId").string_or(""); + + // An empty or missing proxySessionId switches us to legacy single-session + // mode. + if (proxySessionId.empty()) { + disconnectNonLegacySessions(pageId); + } + + auto pageIt = inspectorSessionsByPage_.try_emplace(pageId).first; + + auto sessionIt = pageIt->second.find(proxySessionId); + if (sessionIt != pageIt->second.end()) { + LOG(WARNING) << "Duplicate session, disconnecting (pageId=" << pageId + << ", sessionId=" << proxySessionId << ")"; + disconnectSession({pageIt, sessionIt}); + // NOTE: At least as far back as D52134592, receiving a second + // `connect` message for the same page (and more specifically + // since D90174642, for the same proxySessionId) has been handled by + // disconnecting the previous session and returning *without* creating a new + // one. This seems like a bug and requires more investigation. return; } + int pageIdInt = 0; try { pageIdInt = std::stoi(pageId); @@ -105,60 +154,102 @@ void InspectorPackagerConnection::Impl::handleConnect( auto sessionId = nextSessionId_++; auto remoteConnection = std::make_unique( - weak_from_this(), pageId, sessionId); + weak_from_this(), pageId, sessionId, proxySessionId); auto& inspector = getInspectorInstance(); auto inspectorConnection = inspector.connect(pageIdInt, std::move(remoteConnection)); if (!inspectorConnection) { LOG(INFO) << "Connection to page " << pageId << " rejected"; - // RemoteConnection::onDisconnect(), if the connection even calls it, will - // be a no op (because the session is not added to `inspectorSessions_`), so - // let's always notify the remote client of the disconnection ourselves. + // RemoteConnection::onDisconnect(), if the connection even calls it, will + // be a no op (because the session is not added to + // `inspectorSessionsByPage_`), so let's always notify the remote client + // of the disconnection ourselves. + folly::dynamic disconnectPayload = + folly::dynamic::object("pageId", pageId)("sessionId", proxySessionId); sendToPackager( folly::dynamic::object("event", "disconnect")( - "payload", folly::dynamic::object("pageId", pageId))); + "payload", std::move(disconnectPayload))); return; } - inspectorSessions_.emplace( - pageId, + pageIt->second.emplace( + proxySessionId, Session{ .localConnection = std::move(inspectorConnection), - .sessionId = sessionId}); + .sessionId = sessionId, + .proxySessionId = proxySessionId}); } void InspectorPackagerConnection::Impl::handleDisconnect( const folly::const_dynamic_view& payload) { - std::string pageId = payload.descend("pageId").string_or(INVALID); - auto inspectorConnection = removeConnectionForPage(pageId); - if (inspectorConnection) { - inspectorConnection->disconnect(); + if (auto sessionIterators = findPageAndSession(payload, "disconnect")) { + disconnectSession(*sessionIterators); } } -std::unique_ptr -InspectorPackagerConnection::Impl::removeConnectionForPage( +InspectorPackagerConnection::Impl::InspectorSessionsByPage::iterator +InspectorPackagerConnection::Impl::disconnectSession( + std::pair + sessionIterators) { + auto [pageIt, sessionIt] = sessionIterators; + auto& pageSessions = pageIt->second; + auto connection = std::move(sessionIt->second.localConnection); + pageSessions.erase(sessionIt); + + // Clean up empty page entry + if (pageSessions.empty()) { + inspectorSessionsByPage_.erase(pageIt); + pageIt = inspectorSessionsByPage_.end(); + } + + if (connection) { + connection->disconnect(); + } + + return pageIt; +} + +void InspectorPackagerConnection::Impl::disconnectNonLegacySessions( const std::string& pageId) { - auto it = inspectorSessions_.find(pageId); - if (it != inspectorSessions_.end()) { - auto connection = std::move(it->second); - inspectorSessions_.erase(it); - return std::move(connection.localConnection); + auto pageIt = inspectorSessionsByPage_.find(pageId); + if (pageIt == inspectorSessionsByPage_.end() || pageIt->second.empty()) { + return; + } + + bool logged = false; + while (pageIt != inspectorSessionsByPage_.end()) { + // Find first non-legacy session (non-empty proxySessionId) + auto sessionIt = std::find_if( + pageIt->second.begin(), pageIt->second.end(), [](const auto& entry) { + return !entry.first.empty(); + }); + + if (sessionIt == pageIt->second.end()) { + break; + } + + if (!logged) { + LOG(WARNING) + << "Switching to legacy single-session mode, disconnecting non-legacy " + "sessions (pageId=" + << pageId << ")"; + logged = true; + } + + pageIt = disconnectSession({pageIt, sessionIt}); } - return nullptr; } void InspectorPackagerConnection::Impl::handleWrappedEvent( const folly::const_dynamic_view& payload) { - std::string pageId = payload.descend("pageId").string_or(INVALID); std::string wrappedEvent = payload.descend("wrappedEvent").string_or(INVALID); - auto connectionIt = inspectorSessions_.find(pageId); - if (connectionIt == inspectorSessions_.end()) { - LOG(WARNING) << "Not connected to page: " << pageId - << " , failed trying to handle event: " << wrappedEvent; + + auto maybeSession = findPageAndSession(payload, "wrappedEvent"); + if (!maybeSession) { return; } - connectionIt->second.localConnection->sendMessage(wrappedEvent); + auto [pageIt, sessionIt] = *maybeSession; + sessionIt->second.localConnection->sendMessage(wrappedEvent); } folly::dynamic InspectorPackagerConnection::Impl::pages() { @@ -172,8 +263,12 @@ folly::dynamic InspectorPackagerConnection::Impl::pages() { pageDescription["title"] = appName_ + " (" + deviceName_ + ")"; pageDescription["description"] = page.description + " [C++ connection]"; pageDescription["app"] = appName_; - pageDescription["capabilities"] = + + folly::dynamic capabilities = targetCapabilitiesToDynamic(page.capabilities); + // Report device-level multi-debugger support capability + capabilities["supportsMultipleDebuggers"] = true; + pageDescription["capabilities"] = std::move(capabilities); array.push_back(pageDescription); } @@ -218,9 +313,13 @@ void InspectorPackagerConnection::Impl::didClose() { } void InspectorPackagerConnection::Impl::onPageRemoved(int pageId) { - auto connection = removeConnectionForPage(std::to_string(pageId)); - if (connection) { - connection->disconnect(); + auto pageIt = inspectorSessionsByPage_.find(std::to_string(pageId)); + + while (pageIt != inspectorSessionsByPage_.end() && !pageIt->second.empty()) { + pageIt = disconnectSession({ + pageIt, + pageIt->second.begin(), + }); } } @@ -291,18 +390,24 @@ void InspectorPackagerConnection::Impl::sendToPackager( void InspectorPackagerConnection::Impl::scheduleSendToPackager( folly::dynamic message, SessionId sourceSessionId, - const std::string& sourcePageId) { + const std::string& sourcePageId, + const std::string& sourceProxySessionId) { delegate_->scheduleCallback( [weakSelf = weak_from_this(), message = std::move(message), sourceSessionId, - sourcePageId]() mutable { + sourcePageId, + sourceProxySessionId]() mutable { auto strongSelf = weakSelf.lock(); if (!strongSelf) { return; } - auto sessionIt = strongSelf->inspectorSessions_.find(sourcePageId); - if (sessionIt != strongSelf->inspectorSessions_.end() && + auto pageIt = strongSelf->inspectorSessionsByPage_.find(sourcePageId); + if (pageIt == strongSelf->inspectorSessionsByPage_.end()) { + return; + } + auto sessionIt = pageIt->second.find(sourceProxySessionId); + if (sessionIt != pageIt->second.end() && sessionIt->second.sessionId == sourceSessionId) { strongSelf->sendToPackager(std::move(message)); } @@ -333,10 +438,12 @@ void InspectorPackagerConnection::Impl::disposeWebSocket() { InspectorPackagerConnection::Impl::RemoteConnection::RemoteConnection( std::weak_ptr owningPackagerConnection, std::string pageId, - SessionId sessionId) + SessionId sessionId, + std::string proxySessionId) : owningPackagerConnection_(std::move(owningPackagerConnection)), pageId_(std::move(pageId)), - sessionId_(sessionId) {} + sessionId_(sessionId), + proxySessionId_(std::move(proxySessionId)) {} void InspectorPackagerConnection::Impl::RemoteConnection::onMessage( std::string message) { @@ -344,22 +451,25 @@ void InspectorPackagerConnection::Impl::RemoteConnection::onMessage( if (!owningPackagerConnectionStrong) { return; } + folly::dynamic payload = folly::dynamic::object("pageId", pageId_)( + "wrappedEvent", message)("sessionId", proxySessionId_); owningPackagerConnectionStrong->scheduleSendToPackager( - folly::dynamic::object("event", "wrappedEvent")( - "payload", - folly::dynamic::object("pageId", pageId_)("wrappedEvent", message)), + folly::dynamic::object("event", "wrappedEvent")("payload", payload), sessionId_, - pageId_); + pageId_, + proxySessionId_); } void InspectorPackagerConnection::Impl::RemoteConnection::onDisconnect() { auto owningPackagerConnectionStrong = owningPackagerConnection_.lock(); if (owningPackagerConnectionStrong) { + folly::dynamic payload = + folly::dynamic::object("pageId", pageId_)("sessionId", proxySessionId_); owningPackagerConnectionStrong->scheduleSendToPackager( - folly::dynamic::object("event", "disconnect")( - "payload", folly::dynamic::object("pageId", pageId_)), + folly::dynamic::object("event", "disconnect")("payload", payload), sessionId_, - pageId_); + pageId_, + proxySessionId_); } } diff --git a/packages/react-native/ReactCommon/jsinspector-modern/InspectorPackagerConnectionImpl.h b/packages/react-native/ReactCommon/jsinspector-modern/InspectorPackagerConnectionImpl.h index 9d8ba895d6008a..560e7c2129a64c 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/InspectorPackagerConnectionImpl.h +++ b/packages/react-native/ReactCommon/jsinspector-modern/InspectorPackagerConnectionImpl.h @@ -39,7 +39,6 @@ class InspectorPackagerConnection::Impl : public IWebSocketDelegate, void connect(); void closeQuietly(); void sendEventToAllConnections(const std::string &event); - std::unique_ptr removeConnectionForPage(const std::string &pageId); /** * Send a message to the packager as soon as possible. This method is safe @@ -47,14 +46,21 @@ class InspectorPackagerConnection::Impl : public IWebSocketDelegate, * is sent, in which case the message will be dropped. The message is also * dropped if the session is no longer valid. */ - void scheduleSendToPackager(folly::dynamic message, SessionId sourceSessionId, const std::string &sourcePageId); + void scheduleSendToPackager( + folly::dynamic message, + SessionId sourceSessionId, + const std::string &sourcePageId, + const std::string &sourceProxySessionId); private: struct Session { std::unique_ptr localConnection; SessionId sessionId; + std::string proxySessionId; // Session ID assigned by the proxy }; class RemoteConnection; + using PageSessions = std::unordered_map; + using InspectorSessionsByPage = std::unordered_map; Impl( std::string url, @@ -68,6 +74,31 @@ class InspectorPackagerConnection::Impl : public IWebSocketDelegate, void handleConnect(const folly::const_dynamic_view &payload); void handleWrappedEvent(const folly::const_dynamic_view &payload); void handleProxyMessage(const folly::const_dynamic_view &message); + + /** + * Finds the page and session and referenced by the given message payload. + * Returns a pair of valid (dereferenceable) iterators if found, or nullopt otherwise. + * Supports both legacy single-session mode (proxySessionId == missing or empty) and multi-session mode + * (proxySessionId == some unique identifier assigned by the proxy). + */ + std::optional> findPageAndSession( + const folly::const_dynamic_view &payload, + std::string_view caller); + + /** + * Given a pair of (dereferenceable) iterators as returned by findPageAndSession, disconnects the + * given session. Invalidates the session iterator and may invalidate the page iterator. Returns + * the page iterator if still valid, or the corresponding end() iterator otherwise - this is + * useful when disconnecting multiple sessions in a loop. + */ + InspectorSessionsByPage::iterator disconnectSession( + std::pair sessionIterators); + + /** + * Switch to legacy single-session mode. + */ + void disconnectNonLegacySessions(const std::string &pageId); + folly::dynamic pages(); void reconnect(); void closeAllConnections(); @@ -90,7 +121,9 @@ class InspectorPackagerConnection::Impl : public IWebSocketDelegate, const std::string appName_; const std::unique_ptr delegate_; - std::unordered_map inspectorSessions_; + // Nested map: pageId → (proxySessionId → Session) + // Supports multiple concurrent debugger sessions per page + InspectorSessionsByPage inspectorSessionsByPage_; std::unique_ptr webSocket_; bool connected_{false}; bool closed_{false}; @@ -107,7 +140,8 @@ class InspectorPackagerConnection::Impl::RemoteConnection : public IRemoteConnec RemoteConnection( std::weak_ptr owningPackagerConnection, std::string pageId, - SessionId sessionId); + SessionId sessionId, + std::string proxySessionId); // IRemoteConnection methods void onMessage(std::string message) override; @@ -117,6 +151,7 @@ class InspectorPackagerConnection::Impl::RemoteConnection : public IRemoteConnec const std::weak_ptr owningPackagerConnection_; const std::string pageId_; const SessionId sessionId_; + const std::string proxySessionId_; }; } // namespace facebook::react::jsinspector_modern diff --git a/packages/react-native/ReactCommon/jsinspector-modern/__docs__/InspectorPackagerConnection.md b/packages/react-native/ReactCommon/jsinspector-modern/__docs__/InspectorPackagerConnection.md new file mode 100644 index 00000000000000..4063baa175171b --- /dev/null +++ b/packages/react-native/ReactCommon/jsinspector-modern/__docs__/InspectorPackagerConnection.md @@ -0,0 +1,70 @@ +# InspectorPackagerConnection + +[🏠 Home](../../../../../__docs__/README.md) + +`InspectorPackagerConnection` is the shared C++ implementation of the +device-side inspector-proxy protocol, located in +`ReactCommon/jsinspector-modern/`. + +## 🚀 Usage + +This class handles: + +1. **WebSocket connection** to the inspector proxy at `/inspector/device` +2. **Protocol message handling**: `getPages`, `connect`, `disconnect`, + `wrappedEvent` +3. **Session management**: Routing CDP messages between the proxy and inspector + targets + +### Multi-Debugger Support + +The implementation supports multiple concurrent debugger sessions per page via +`sessionId` routing: + +- **Incoming `connect`**: Creates a new session keyed by `pageId` + `sessionId` +- **Incoming `wrappedEvent`**: Routes to the specific session by `sessionId` +- **Outgoing `wrappedEvent`**: Includes `sessionId` for proxy routing +- **Capability reporting**: Reports `supportsMultipleDebuggers: true` in page + capabilities + +## 📐 Design + +For canonical protocol documentation, see: +**[Inspector Proxy Protocol](../../../../dev-middleware/src/inspector-proxy/__docs__/README.md)** + +### Session Data Structure + +Sessions are stored in a nested map for efficient page-level operations: + +```cpp +std::unordered_map> inspectorSessions_; +// pageId → (sessionId → Session) +``` + +### Compatibility with Legacy Proxies + +When connected to a legacy proxy that doesn't send `sessionId`: + +- Device treats the connection as a single-session legacy connection +- Device generates an internal session ID for routing +- Device includes `sessionId` in outgoing messages (legacy proxies ignore it) + +## 🔗 Relationship with other systems + +### Part of + +- React Native jsinspector-modern + +### Uses this + +- **Inspector Proxy** (`dev-middleware/src/inspector-proxy/`) - The Node.js + proxy that this connects to +- **Platform delegates** - iOS (`RCTInspectorDevServerHelper.mm`), Android + (`DevServerHelper.kt`), ReactCxxPlatform (`Inspector.cpp`) provide WebSocket + I/O + +### Used by this + +- **IInspector** - The inspector instance for page registration and connection +- **HostTarget / InstanceTarget / RuntimeTarget** - The CDP targets that handle + debugger sessions diff --git a/packages/react-native/ReactCommon/jsinspector-modern/tests/InspectorPackagerConnectionMultiSessionTest.cpp b/packages/react-native/ReactCommon/jsinspector-modern/tests/InspectorPackagerConnectionMultiSessionTest.cpp new file mode 100644 index 00000000000000..a8dd82823d91cf --- /dev/null +++ b/packages/react-native/ReactCommon/jsinspector-modern/tests/InspectorPackagerConnectionMultiSessionTest.cpp @@ -0,0 +1,692 @@ +/* + * 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 "FollyDynamicMatchers.h" +#include "InspectorPackagerConnectionTest.h" + +/** + * Tests for multi-session support in InspectorPackagerConnection. + * + * These tests verify that multiple debugger sessions can connect to + * the same page simultaneously using session IDs. + */ + +using namespace ::testing; +using namespace std::literals::chrono_literals; +using namespace std::literals::string_literals; +using folly::toJson; + +namespace facebook::react::jsinspector_modern { + +using InspectorPackagerConnectionMultiSessionTest = + InspectorPackagerConnectionTest; + +TEST_F( + InspectorPackagerConnectionMultiSessionTest, + TestReportsMultiDebuggerCapability) { + InSequence mockCallsMustBeInSequence; + + packagerConnection_->connect(); + + auto pageId = getInspectorInstance().addPage( + "mock-description", + "mock-vm", + localConnections_ + .lazily_make_unique>()); + + // getPages should report supportsMultipleDebuggers: true + EXPECT_CALL( + *webSockets_[0], + send(JsonParsed(AllOf( + AtJsonPtr("/event", Eq("getPages")), + AtJsonPtr( + "/payload", + Contains(AllOf( + AtJsonPtr("/id", Eq(std::to_string(pageId))), + AtJsonPtr( + "/capabilities/supportsMultipleDebuggers", + Eq(true))))))))) + .RetiresOnSaturation(); + webSockets_[0]->getDelegate().didReceiveMessage(R"({ + "event": "getPages" + })"); + + getInspectorInstance().removePage(pageId); +} + +TEST_F( + InspectorPackagerConnectionMultiSessionTest, + TestMultipleSessionsConnectToSamePage) { + auto mockCallsMustBeInSequence = std::make_optional(); + + packagerConnection_->connect(); + + auto pageId = getInspectorInstance().addPage( + "mock-description", + "mock-vm", + localConnections_ + .lazily_make_unique>()); + + // Connect first session with sessionId + webSockets_[0]->getDelegate().didReceiveMessage( + fmt::format( + R"({{ + "event": "connect", + "payload": {{ + "pageId": {0}, + "sessionId": "session-1" + }} + }})", + toJson(std::to_string(pageId)))); + ASSERT_TRUE(localConnections_[0]); + + // Connect second session with different sessionId + webSockets_[0]->getDelegate().didReceiveMessage( + fmt::format( + R"({{ + "event": "connect", + "payload": {{ + "pageId": {0}, + "sessionId": "session-2" + }} + }})", + toJson(std::to_string(pageId)))); + ASSERT_TRUE(localConnections_[1]); + + // Both connections should still be active + EXPECT_TRUE(localConnections_[0]); + EXPECT_TRUE(localConnections_[1]); + + // The `disconnect` calls are not guaranteed to happen in order, so tear down + // our InSequence guard before cleaning up. + mockCallsMustBeInSequence.reset(); + EXPECT_CALL(*localConnections_[0], disconnect()).RetiresOnSaturation(); + EXPECT_CALL(*localConnections_[1], disconnect()).RetiresOnSaturation(); + getInspectorInstance().removePage(pageId); +} + +TEST_F( + InspectorPackagerConnectionMultiSessionTest, + TestWrappedEventRoutedBySessionId) { + auto mockCallsMustBeInSequence = std::make_optional(); + + packagerConnection_->connect(); + + auto pageId = getInspectorInstance().addPage( + "mock-description", + "mock-vm", + localConnections_ + .lazily_make_unique>()); + + // Connect two sessions + webSockets_[0]->getDelegate().didReceiveMessage( + fmt::format( + R"({{ + "event": "connect", + "payload": {{ + "pageId": {0}, + "sessionId": "session-1" + }} + }})", + toJson(std::to_string(pageId)))); + ASSERT_TRUE(localConnections_[0]); + + webSockets_[0]->getDelegate().didReceiveMessage( + fmt::format( + R"({{ + "event": "connect", + "payload": {{ + "pageId": {0}, + "sessionId": "session-2" + }} + }})", + toJson(std::to_string(pageId)))); + ASSERT_TRUE(localConnections_[1]); + + // Send a message to session-1 only + EXPECT_CALL( + *localConnections_[0], + sendMessage(JsonParsed(AtJsonPtr("/method", Eq("FakeDomain.method1"))))) + .RetiresOnSaturation(); + webSockets_[0]->getDelegate().didReceiveMessage( + fmt::format( + R"({{ + "event": "wrappedEvent", + "payload": {{ + "pageId": {0}, + "sessionId": "session-1", + "wrappedEvent": {1} + }} + }})", + toJson(std::to_string(pageId)), + toJson(R"({"method": "FakeDomain.method1"})"))); + + // Send a message to session-2 only + EXPECT_CALL( + *localConnections_[1], + sendMessage(JsonParsed(AtJsonPtr("/method", Eq("FakeDomain.method2"))))) + .RetiresOnSaturation(); + webSockets_[0]->getDelegate().didReceiveMessage( + fmt::format( + R"({{ + "event": "wrappedEvent", + "payload": {{ + "pageId": {0}, + "sessionId": "session-2", + "wrappedEvent": {1} + }} + }})", + toJson(std::to_string(pageId)), + toJson(R"({"method": "FakeDomain.method2"})"))); + + // The `disconnect` calls are not guaranteed to happen in order, so tear down + // our InSequence guard before cleaning up. + mockCallsMustBeInSequence.reset(); + EXPECT_CALL(*localConnections_[0], disconnect()).RetiresOnSaturation(); + EXPECT_CALL(*localConnections_[1], disconnect()).RetiresOnSaturation(); + getInspectorInstance().removePage(pageId); +} + +TEST_F(InspectorPackagerConnectionMultiSessionTest, TestDisconnectBySessionId) { + InSequence mockCallsMustBeInSequence; + + packagerConnection_->connect(); + + auto pageId = getInspectorInstance().addPage( + "mock-description", + "mock-vm", + localConnections_ + .lazily_make_unique>()); + + // Connect two sessions + webSockets_[0]->getDelegate().didReceiveMessage( + fmt::format( + R"({{ + "event": "connect", + "payload": {{ + "pageId": {0}, + "sessionId": "session-1" + }} + }})", + toJson(std::to_string(pageId)))); + ASSERT_TRUE(localConnections_[0]); + + webSockets_[0]->getDelegate().didReceiveMessage( + fmt::format( + R"({{ + "event": "connect", + "payload": {{ + "pageId": {0}, + "sessionId": "session-2" + }} + }})", + toJson(std::to_string(pageId)))); + ASSERT_TRUE(localConnections_[1]); + + // Disconnect session-1 only + EXPECT_CALL(*localConnections_[0], disconnect()).RetiresOnSaturation(); + webSockets_[0]->getDelegate().didReceiveMessage( + fmt::format( + R"({{ + "event": "disconnect", + "payload": {{ + "pageId": {0}, + "sessionId": "session-1" + }} + }})", + toJson(std::to_string(pageId)))); + EXPECT_FALSE(localConnections_[0]); + + // Session-2 should still be active + EXPECT_TRUE(localConnections_[1]); + + // Can still send messages to session-2 + EXPECT_CALL( + *localConnections_[1], + sendMessage(JsonParsed(AtJsonPtr("/method", Eq("FakeDomain.method"))))) + .RetiresOnSaturation(); + webSockets_[0]->getDelegate().didReceiveMessage( + fmt::format( + R"({{ + "event": "wrappedEvent", + "payload": {{ + "pageId": {0}, + "sessionId": "session-2", + "wrappedEvent": {1} + }} + }})", + toJson(std::to_string(pageId)), + toJson(R"({"method": "FakeDomain.method"})"))); + + // Clean up + EXPECT_CALL(*localConnections_[1], disconnect()).RetiresOnSaturation(); + getInspectorInstance().removePage(pageId); +} + +TEST_F( + InspectorPackagerConnectionMultiSessionTest, + TestOutgoingMessagesIncludeSessionId) { + InSequence mockCallsMustBeInSequence; + + packagerConnection_->connect(); + + auto pageId = getInspectorInstance().addPage( + "mock-description", + "mock-vm", + localConnections_ + .lazily_make_unique>()); + + // Connect with a sessionId + webSockets_[0]->getDelegate().didReceiveMessage( + fmt::format( + R"({{ + "event": "connect", + "payload": {{ + "pageId": {0}, + "sessionId": "my-session" + }} + }})", + toJson(std::to_string(pageId)))); + ASSERT_TRUE(localConnections_[0]); + + // When the local connection sends a message, it should include the sessionId + EXPECT_CALL( + *webSockets_[0], + send(JsonParsed(AllOf( + AtJsonPtr("/event", Eq("wrappedEvent")), + AtJsonPtr("/payload/pageId", Eq(std::to_string(pageId))), + AtJsonPtr("/payload/sessionId", Eq("my-session")), + AtJsonPtr( + "/payload/wrappedEvent", + JsonParsed(AtJsonPtr("/method", Eq("FakeDomain.event")))))))) + .RetiresOnSaturation(); + localConnections_[0]->getRemoteConnection().onMessage( + R"({"method": "FakeDomain.event"})"); + + // When the local connection disconnects, it should include the sessionId + EXPECT_CALL( + *webSockets_[0], + send(JsonParsed(AllOf( + AtJsonPtr("/event", Eq("disconnect")), + AtJsonPtr("/payload/pageId", Eq(std::to_string(pageId))), + AtJsonPtr("/payload/sessionId", Eq("my-session")))))) + .RetiresOnSaturation(); + localConnections_[0]->getRemoteConnection().onDisconnect(); + + EXPECT_CALL(*localConnections_[0], disconnect()).RetiresOnSaturation(); + getInspectorInstance().removePage(pageId); +} + +TEST_F( + InspectorPackagerConnectionTest, + TestLegacyConnectionWithEmptySessionId) { + InSequence mockCallsMustBeInSequence; + + packagerConnection_->connect(); + + auto pageId = getInspectorInstance().addPage( + "mock-description", + "mock-vm", + localConnections_ + .lazily_make_unique>()); + + // Connect without sessionId (legacy proxy). This is treated as an empty + // string sessionId. + webSockets_[0]->getDelegate().didReceiveMessage( + fmt::format( + R"({{ + "event": "connect", + "payload": {{ + "pageId": {0} + }} + }})", + toJson(std::to_string(pageId)))); + ASSERT_TRUE(localConnections_[0]); + + // Incoming messages are routed correctly when sessionId is omitted + EXPECT_CALL( + *localConnections_[0], + sendMessage(JsonParsed(AtJsonPtr("/method", Eq("FakeDomain.method"))))) + .RetiresOnSaturation(); + webSockets_[0]->getDelegate().didReceiveMessage( + fmt::format( + R"({{ + "event": "wrappedEvent", + "payload": {{ + "pageId": {0}, + "wrappedEvent": {1} + }} + }})", + toJson(std::to_string(pageId)), + toJson(R"({"method": "FakeDomain.method"})"))); + + // Outgoing messages include the empty string sessionId + EXPECT_CALL( + *webSockets_[0], + send(JsonParsed(AllOf( + AtJsonPtr("/event", Eq("wrappedEvent")), + AtJsonPtr("/payload/pageId", Eq(std::to_string(pageId))), + AtJsonPtr("/payload/sessionId", Eq("")), + AtJsonPtr( + "/payload/wrappedEvent", + JsonParsed(AtJsonPtr("/method", Eq("FakeDomain.event")))))))) + .RetiresOnSaturation(); + localConnections_[0]->getRemoteConnection().onMessage( + R"({"method": "FakeDomain.event"})"); + + // Disconnect when sessionId is omitted + EXPECT_CALL(*localConnections_[0], disconnect()).RetiresOnSaturation(); + webSockets_[0]->getDelegate().didReceiveMessage( + fmt::format( + R"({{ + "event": "disconnect", + "payload": {{ + "pageId": {0} + }} + }})", + toJson(std::to_string(pageId)))); + EXPECT_FALSE(localConnections_[0]); +} + +TEST_F( + InspectorPackagerConnectionTest, + TestDuplicateSessionIdDisconnectsExisting) { + // NOTE: See TestConnectWhileAlreadyConnectedCausesDisconnection for the + // equivalent legacy (empty session ID) case. + InSequence mockCallsMustBeInSequence; + + packagerConnection_->connect(); + + auto pageId = getInspectorInstance().addPage( + "mock-description", + "mock-vm", + localConnections_ + .lazily_make_unique>()); + + // Connect with sessionId + webSockets_[0]->getDelegate().didReceiveMessage( + fmt::format( + R"({{ + "event": "connect", + "payload": {{ + "pageId": {0}, + "sessionId": "session-1" + }} + }})", + toJson(std::to_string(pageId)))); + ASSERT_TRUE(localConnections_[0]); + + // Try to connect again with the same sessionId - should disconnect existing + EXPECT_CALL(*localConnections_[0], disconnect()).RetiresOnSaturation(); + webSockets_[0]->getDelegate().didReceiveMessage( + fmt::format( + R"({{ + "event": "connect", + "payload": {{ + "pageId": {0}, + "sessionId": "session-1" + }} + }})", + toJson(std::to_string(pageId)))); + + // Original connection was disconnected, no new connection created + EXPECT_FALSE(localConnections_[0]); + EXPECT_EQ(localConnections_.objectsVended(), 1); +} + +TEST_F( + InspectorPackagerConnectionMultiSessionTest, + TestPageRemovalDisconnectsAllSessions) { + auto mockCallsMustBeInSequence = std::make_optional(); + + packagerConnection_->connect(); + + auto pageId = getInspectorInstance().addPage( + "mock-description", + "mock-vm", + localConnections_ + .lazily_make_unique>()); + + // Connect multiple sessions + webSockets_[0]->getDelegate().didReceiveMessage( + fmt::format( + R"({{ + "event": "connect", + "payload": {{ + "pageId": {0}, + "sessionId": "session-1" + }} + }})", + toJson(std::to_string(pageId)))); + ASSERT_TRUE(localConnections_[0]); + + webSockets_[0]->getDelegate().didReceiveMessage( + fmt::format( + R"({{ + "event": "connect", + "payload": {{ + "pageId": {0}, + "sessionId": "session-2" + }} + }})", + toJson(std::to_string(pageId)))); + ASSERT_TRUE(localConnections_[1]); + + webSockets_[0]->getDelegate().didReceiveMessage( + fmt::format( + R"({{ + "event": "connect", + "payload": {{ + "pageId": {0}, + "sessionId": "session-3" + }} + }})", + toJson(std::to_string(pageId)))); + ASSERT_TRUE(localConnections_[2]); + + // Remove the page - all sessions should be disconnected. + // This is not guaranteed to be in order, so tear down our InSequence guard. + mockCallsMustBeInSequence.reset(); + + EXPECT_CALL(*localConnections_[0], disconnect()); + EXPECT_CALL(*localConnections_[1], disconnect()); + EXPECT_CALL(*localConnections_[2], disconnect()); + getInspectorInstance().removePage(pageId); + + EXPECT_FALSE(localConnections_[0]); + EXPECT_FALSE(localConnections_[1]); + EXPECT_FALSE(localConnections_[2]); +} + +TEST_F( + InspectorPackagerConnectionTest, + TestLegacyConnectThenMultiSessionConnect) { + // Tests the edge case where a peer starts in legacy mode (empty sessionId) + // and later sends a connect with a non-empty sessionId. + auto mockCallsMustBeInSequence = std::make_optional(); + + packagerConnection_->connect(); + + auto pageId = getInspectorInstance().addPage( + "mock-description", + "mock-vm", + localConnections_ + .lazily_make_unique>()); + + // First connect in legacy mode (no sessionId) + webSockets_[0]->getDelegate().didReceiveMessage( + fmt::format( + R"({{ + "event": "connect", + "payload": {{ + "pageId": {0} + }} + }})", + toJson(std::to_string(pageId)))); + ASSERT_TRUE(localConnections_[0]); + + // Second connect with a sessionId - this is a different session, + // so both should coexist (legacy session uses empty string as key) + webSockets_[0]->getDelegate().didReceiveMessage( + fmt::format( + R"({{ + "event": "connect", + "payload": {{ + "pageId": {0}, + "sessionId": "session-1" + }} + }})", + toJson(std::to_string(pageId)))); + ASSERT_TRUE(localConnections_[1]); + + // Both connections should be active + EXPECT_TRUE(localConnections_[0]); + EXPECT_TRUE(localConnections_[1]); + + // Messages to the multi-session connection should work + EXPECT_CALL( + *localConnections_[1], + sendMessage(JsonParsed(AtJsonPtr("/method", Eq("FakeDomain.method1"))))) + .RetiresOnSaturation(); + webSockets_[0]->getDelegate().didReceiveMessage( + fmt::format( + R"({{ + "event": "wrappedEvent", + "payload": {{ + "pageId": {0}, + "sessionId": "session-1", + "wrappedEvent": {1} + }} + }})", + toJson(std::to_string(pageId)), + toJson(R"({"method": "FakeDomain.method1"})"))); + + // Messages to the legacy connection should also work + EXPECT_CALL( + *localConnections_[0], + sendMessage(JsonParsed(AtJsonPtr("/method", Eq("FakeDomain.method2"))))) + .RetiresOnSaturation(); + webSockets_[0]->getDelegate().didReceiveMessage( + fmt::format( + R"({{ + "event": "wrappedEvent", + "payload": {{ + "pageId": {0}, + "wrappedEvent": {1} + }} + }})", + toJson(std::to_string(pageId)), + toJson(R"({"method": "FakeDomain.method2"})"))); + + // Remove the page - all sessions should be disconnected. + // This is not guaranteed to be in order, so tear down our InSequence guard. + mockCallsMustBeInSequence.reset(); + EXPECT_CALL(*localConnections_[0], disconnect()).RetiresOnSaturation(); + EXPECT_CALL(*localConnections_[1], disconnect()).RetiresOnSaturation(); + getInspectorInstance().removePage(pageId); +} + +TEST_F( + InspectorPackagerConnectionTest, + TestMultiSessionConnectThenLegacyConnect) { + // Tests the edge case where a peer starts in multi-session mode + // and later sends a connect in legacy mode (empty sessionId). + // When switching to legacy mode, all non-legacy sessions should be + // disconnected first, as it implies the peer won't handle concurrent sessions + // correctly. + auto mockCallsMustBeInSequence = std::make_optional(); + + packagerConnection_->connect(); + + auto pageId = getInspectorInstance().addPage( + "mock-description", + "mock-vm", + localConnections_ + .lazily_make_unique>()); + + // First connect with a sessionId (multi-session mode) + webSockets_[0]->getDelegate().didReceiveMessage( + fmt::format( + R"({{ + "event": "connect", + "payload": {{ + "pageId": {0}, + "sessionId": "session-1" + }} + }})", + toJson(std::to_string(pageId)))); + ASSERT_TRUE(localConnections_[0]); + + // Connect another multi-session connection + webSockets_[0]->getDelegate().didReceiveMessage( + fmt::format( + R"({{ + "event": "connect", + "payload": {{ + "pageId": {0}, + "sessionId": "session-2" + }} + }})", + toJson(std::to_string(pageId)))); + ASSERT_TRUE(localConnections_[1]); + + // Both connections should be active + EXPECT_TRUE(localConnections_[0]); + EXPECT_TRUE(localConnections_[1]); + + // When we switch to legacy mode, all non-legacy sessions should be + // disconnected (not guaranteed to be in order) + mockCallsMustBeInSequence.reset(); + EXPECT_CALL(*localConnections_[0], disconnect()).RetiresOnSaturation(); + EXPECT_CALL(*localConnections_[1], disconnect()).RetiresOnSaturation(); + mockCallsMustBeInSequence.emplace(); + + // Now connect in legacy mode (no sessionId) + webSockets_[0]->getDelegate().didReceiveMessage( + fmt::format( + R"({{ + "event": "connect", + "payload": {{ + "pageId": {0} + }} + }})", + toJson(std::to_string(pageId)))); + ASSERT_TRUE(localConnections_[2]); + + // Only the legacy connection should be active now + EXPECT_FALSE(localConnections_[0]); + EXPECT_FALSE(localConnections_[1]); + EXPECT_TRUE(localConnections_[2]); + + // Messages to the legacy connection should work + EXPECT_CALL( + *localConnections_[2], + sendMessage(JsonParsed(AtJsonPtr("/method", Eq("FakeDomain.method"))))) + .RetiresOnSaturation(); + webSockets_[0]->getDelegate().didReceiveMessage( + fmt::format( + R"({{ + "event": "wrappedEvent", + "payload": {{ + "pageId": {0}, + "wrappedEvent": {1} + }} + }})", + toJson(std::to_string(pageId)), + toJson(R"({"method": "FakeDomain.method"})"))); + + // Clean up + EXPECT_CALL(*localConnections_[2], disconnect()).RetiresOnSaturation(); + getInspectorInstance().removePage(pageId); +} + +} // namespace facebook::react::jsinspector_modern diff --git a/packages/react-native/ReactCommon/jsinspector-modern/tests/InspectorPackagerConnectionTest.cpp b/packages/react-native/ReactCommon/jsinspector-modern/tests/InspectorPackagerConnectionTest.cpp index 696dbdedf0f359..7045e0e064256f 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/tests/InspectorPackagerConnectionTest.cpp +++ b/packages/react-native/ReactCommon/jsinspector-modern/tests/InspectorPackagerConnectionTest.cpp @@ -119,8 +119,10 @@ TEST_F(InspectorPackagerConnectionTest, TestGetPages) { AtJsonPtr("/app", Eq("my-app")), AtJsonPtr("/capabilities/nativePageReloads", Eq(true)), AtJsonPtr( - "/capabilities/nativeSourceCodeFetching", - Eq(false))), + "/capabilities/nativeSourceCodeFetching", Eq(false)), + AtJsonPtr( + "/capabilities/supportsMultipleDebuggers", + Eq(true))), AllOf( AtJsonPtr("/id", Eq(std::to_string(pageId2))), AtJsonPtr("/title", Eq("my-app (my-device)")), @@ -130,8 +132,10 @@ TEST_F(InspectorPackagerConnectionTest, TestGetPages) { AtJsonPtr("/app", Eq("my-app")), AtJsonPtr("/capabilities/nativePageReloads", Eq(true)), AtJsonPtr( - "/capabilities/nativeSourceCodeFetching", - Eq(false)))})))))) + "/capabilities/nativeSourceCodeFetching", Eq(false)), + AtJsonPtr( + "/capabilities/supportsMultipleDebuggers", + Eq(true)))})))))) .RetiresOnSaturation(); webSockets_[0]->getDelegate().didReceiveMessage(R"({ "event": "getPages" @@ -544,6 +548,7 @@ TEST_F( }})", toJson(std::to_string(pageId)))); EXPECT_FALSE(localConnections_[0]); + EXPECT_EQ(localConnections_.objectsVended(), 1); } TEST_F(InspectorPackagerConnectionTest, TestMultipleDisconnect) {