From f9b71445b7b724ad3108e8dadd588447a0939e87 Mon Sep 17 00:00:00 2001 From: Rob Hogan Date: Sun, 9 Jun 2024 09:56:00 -0700 Subject: [PATCH] Implement Network.loadNetworkResource etc in C++ (#44845) Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/44845 ## Design - `NetworkIO` is an object owned by the `HostAgent`, created by `HostTarget` where it is given a scoped executor. - `HostAgent` passes most handling of CDP `Network.loadNetworkResource` through `NetworkIO`. - `NetworkIO.loadNetworkResource` creates and holds a shared_ptr to a `Stream` representing a single resource load, and owning received headers and data. A reference is held in a map `streams_` until an error or it is closed with `IO.close`. `delegate.networkRequest` is called with the `stream`, which it retains for the lifetime of the request, and uses its methods to call back with headers, data and errors. - Callbacks for `IO.read` requests are held by the `Stream` until the incoming data is complete or enough data is available to fill the request (an implementation choice to optimise for fewest round trips). Any incoming data or error causes any pending requests to be rechecked. ## Unimplemented platforms - Platforms may optionally implement `HostTargetDelegate.networkRequest` (as of this diff, none do). If they don't we report a CDP "not implemented" error, similar to the status quo where it was unimplemented by the C++ agent. Differential Revision: D54309633 --- .../jsinspector-modern/HostAgent.cpp | 139 ++++++++- .../jsinspector-modern/HostAgent.h | 10 + .../jsinspector-modern/HostTarget.cpp | 23 +- .../jsinspector-modern/HostTarget.h | 33 ++- .../jsinspector-modern/InspectorInterfaces.h | 15 +- .../jsinspector-modern/NetworkIO.cpp | 212 +++++++++++++ .../jsinspector-modern/NetworkIO.h | 203 +++++++++++++ .../tests/HostTargetTest.cpp | 279 ++++++++++++++++++ .../jsinspector-modern/tests/InspectorMocks.h | 6 + 9 files changed, 909 insertions(+), 11 deletions(-) create mode 100644 packages/react-native/ReactCommon/jsinspector-modern/NetworkIO.cpp create mode 100644 packages/react-native/ReactCommon/jsinspector-modern/NetworkIO.h diff --git a/packages/react-native/ReactCommon/jsinspector-modern/HostAgent.cpp b/packages/react-native/ReactCommon/jsinspector-modern/HostAgent.cpp index f818438db6ef9e..1d1d501c0504f4 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/HostAgent.cpp +++ b/packages/react-native/ReactCommon/jsinspector-modern/HostAgent.cpp @@ -32,6 +32,7 @@ HostAgent::HostAgent( : frontendChannel_(frontendChannel), targetController_(targetController), sessionMetadata_(std::move(sessionMetadata)), + networkIO_(targetController.createNetworkHandler()), sessionState_(sessionState) {} void HostAgent::handleRequest(const cdp::PreparsedRequest& req) { @@ -151,6 +152,15 @@ void HostAgent::handleRequest(const cdp::PreparsedRequest& req) { folly::dynamic::object("dataLossOccurred", false))); shouldSendOKResponse = true; isFinishedHandlingRequest = true; + } else if (req.method == "Network.loadNetworkResource") { + handleLoadNetworkResource(req); + return; + } else if (req.method == "IO.read") { + handleIoRead(req); + return; + } else if (req.method == "IO.close") { + handleIoClose(req); + return; } if (!isFinishedHandlingRequest && instanceAgent_ && @@ -163,10 +173,131 @@ void HostAgent::handleRequest(const cdp::PreparsedRequest& req) { return; } - frontendChannel_(cdp::jsonError( - req.id, - cdp::ErrorCode::MethodNotFound, - req.method + " not implemented yet")); + throw NotImplementedException(req.method); +} + +void HostAgent::handleLoadNetworkResource(const cdp::PreparsedRequest& req) { + long long requestId = req.id; + auto res = folly::dynamic::object("id", requestId); + if (!req.params.isObject()) { + frontendChannel_(cdp::jsonError( + req.id, + cdp::ErrorCode::InvalidParams, + "Invalid params: not an object.")); + return; + } + if ((req.params.count("url") == 0u) || !req.params.at("url").isString()) { + frontendChannel_(cdp::jsonError( + requestId, + cdp::ErrorCode::InvalidParams, + "Invalid params: url is missing or not a string.")); + return; + } + + networkIO_->loadNetworkResource( + {.url = req.params.at("url").asString()}, + targetController_.getDelegate(), + // This callback is always called, with resource.success=false on failure. + [requestId, + frontendChannel = frontendChannel_](NetworkResource resource) { + auto dynamicResource = + folly::dynamic::object("success", resource.success); + + if (resource.stream) { + dynamicResource("stream", *resource.stream); + } + + if (resource.netErrorName) { + dynamicResource("netErrorName", *resource.netErrorName); + } + + if (resource.httpStatusCode) { + dynamicResource("httpStatusCode", *resource.httpStatusCode); + } + + if (resource.headers) { + auto dynamicHeaders = folly::dynamic::object(); + for (const auto& pair : *resource.headers) { + dynamicHeaders(pair.first, pair.second); + } + dynamicResource("headers", std::move(dynamicHeaders)); + } + + frontendChannel(cdp::jsonResult( + requestId, + folly::dynamic::object("resource", std::move(dynamicResource)))); + }); +} + +void HostAgent::handleIoRead(const cdp::PreparsedRequest& req) { + long long requestId = req.id; + if (!req.params.isObject()) { + frontendChannel_(cdp::jsonError( + requestId, + cdp::ErrorCode::InvalidParams, + "Invalid params: not an object.")); + return; + } + if ((req.params.count("handle") == 0u) || + !req.params.at("handle").isString()) { + frontendChannel_(cdp::jsonError( + requestId, + cdp::ErrorCode::InvalidParams, + "Invalid params: handle is missing or not a string.")); + return; + } + std::optional size = std::nullopt; + if ((req.params.count("size") != 0u) && req.params.at("size").isInt()) { + size = req.params.at("size").asInt(); + } + networkIO_->readStream( + {.handle = req.params.at("handle").asString(), .size = size}, + [requestId, frontendChannel = frontendChannel_]( + std::variant resultOrError) { + if (std::holds_alternative(resultOrError)) { + frontendChannel(cdp::jsonError( + requestId, + cdp::ErrorCode::InternalError, + std::get(resultOrError))); + } else { + const auto& result = std::get(resultOrError); + auto stringResult = cdp::jsonResult( + requestId, + folly::dynamic::object("data", result.data)("eof", result.eof)( + "base64Encoded", result.base64Encoded)); + frontendChannel(stringResult); + } + }); +} + +void HostAgent::handleIoClose(const cdp::PreparsedRequest& req) { + long long requestId = req.id; + if (!req.params.isObject()) { + frontendChannel_(cdp::jsonError( + requestId, + cdp::ErrorCode::InvalidParams, + "Invalid params: not an object.")); + return; + } + if ((req.params.count("handle") == 0u) || + !req.params.at("handle").isString()) { + frontendChannel_(cdp::jsonError( + requestId, + cdp::ErrorCode::InvalidParams, + "Invalid params: handle is missing or not a string.")); + return; + } + networkIO_->closeStream( + req.params.at("handle").asString(), + [requestId, frontendChannel = frontendChannel_]( + std::optional maybeError) { + if (maybeError) { + frontendChannel(cdp::jsonError( + requestId, cdp::ErrorCode::InternalError, *maybeError)); + } else { + frontendChannel(cdp::jsonResult(requestId)); + } + }); } HostAgent::~HostAgent() { diff --git a/packages/react-native/ReactCommon/jsinspector-modern/HostAgent.h b/packages/react-native/ReactCommon/jsinspector-modern/HostAgent.h index 3424f79bd9bdc7..941341a88fcb68 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/HostAgent.h +++ b/packages/react-native/ReactCommon/jsinspector-modern/HostAgent.h @@ -7,7 +7,9 @@ #pragma once +#include "CdpJson.h" #include "HostTarget.h" +#include "NetworkIO.h" #include "SessionState.h" #include @@ -98,12 +100,20 @@ class HostAgent final { std::shared_ptr instanceAgent_; FuseboxClientType fuseboxClientType_{FuseboxClientType::Unknown}; bool isPausedInDebuggerOverlayVisible_{false}; + std::shared_ptr networkIO_; /** * A shared reference to the session's state. This is only safe to access * during handleRequest and other method calls on the same thread. */ SessionState& sessionState_; + + /** Handle a Network.loadNetworkResource CDP request. */ + void handleLoadNetworkResource(const cdp::PreparsedRequest& req); + /** Handle an IO.read CDP request. */ + void handleIoRead(const cdp::PreparsedRequest& req); + /** Handle an IO.close CDP request. */ + void handleIoClose(const cdp::PreparsedRequest& req); }; } // namespace facebook::react::jsinspector_modern diff --git a/packages/react-native/ReactCommon/jsinspector-modern/HostTarget.cpp b/packages/react-native/ReactCommon/jsinspector-modern/HostTarget.cpp index 2e682460a94539..3a10f8ff935c72 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/HostTarget.cpp +++ b/packages/react-native/ReactCommon/jsinspector-modern/HostTarget.cpp @@ -62,15 +62,22 @@ class HostTargetSession { return; } - // Catch exceptions that may arise from accessing dynamic params during - // request handling. try { hostAgent_.handleRequest(request); - } catch (const cdp::TypeError& e) { + } + // Catch exceptions that may arise from accessing dynamic params during + // request handling. + catch (const cdp::TypeError& e) { frontendChannel_( cdp::jsonError(request.id, cdp::ErrorCode::InvalidRequest, e.what())); return; } + // Catch exceptions for unrecognised or partially implemented CDP methods. + catch (const NotImplementedException& e) { + frontendChannel_( + cdp::jsonError(request.id, cdp::ErrorCode::MethodNotFound, e.what())); + return; + } } /** @@ -198,6 +205,12 @@ void HostTarget::sendCommand(HostCommand command) { }); } +std::shared_ptr HostTarget::createNetworkHandler() { + auto networkIO = std::make_shared(); + networkIO->setExecutor(executorFromThis()); + return networkIO; +} + HostTargetController::HostTargetController(HostTarget& target) : target_(target) {} @@ -205,6 +218,10 @@ HostTargetDelegate& HostTargetController::getDelegate() { return target_.getDelegate(); } +std::shared_ptr HostTargetController::createNetworkHandler() { + return target_.createNetworkHandler(); +}; + bool HostTargetController::hasInstance() const { return target_.hasInstance(); } diff --git a/packages/react-native/ReactCommon/jsinspector-modern/HostTarget.h b/packages/react-native/ReactCommon/jsinspector-modern/HostTarget.h index 6e4fafbd30d2e6..6d59b6a7c7ee7d 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/HostTarget.h +++ b/packages/react-native/ReactCommon/jsinspector-modern/HostTarget.h @@ -11,6 +11,7 @@ #include "HostCommand.h" #include "InspectorInterfaces.h" #include "InstanceTarget.h" +#include "NetworkIO.h" #include "ScopedExecutor.h" #include "WeakList.h" @@ -41,13 +42,13 @@ class HostTarget; * React Native platform needs to implement in order to integrate with the * debugging stack. */ -class HostTargetDelegate { +class HostTargetDelegate : public NetworkRequestDelegate { public: HostTargetDelegate() = default; HostTargetDelegate(const HostTargetDelegate&) = delete; - HostTargetDelegate(HostTargetDelegate&&) = default; + HostTargetDelegate(HostTargetDelegate&&) = delete; HostTargetDelegate& operator=(const HostTargetDelegate&) = delete; - HostTargetDelegate& operator=(HostTargetDelegate&&) = default; + HostTargetDelegate& operator=(HostTargetDelegate&&) = delete; // TODO(moti): This is 1:1 the shape of the corresponding CDP message - // consider reusing typed/generated CDP interfaces when we have those. @@ -104,6 +105,19 @@ class HostTargetDelegate { */ virtual void onSetPausedInDebuggerMessage( const OverlaySetPausedInDebuggerMessageRequest& request) = 0; + + /** + * Called by NetworkIO on handling a `Network.loadNetworkResource` CDP + * request. Platform implementations should override this to perform a + * network request of the given URL, and use listener's callbacks on receipt + * of headers, data chunks, and errors. + */ + void networkRequest( + const std::string& /*url*/, + std::shared_ptr /*listener*/) override { + throw NotImplementedException( + "NetworkRequestDelegate.networkRequest is not implemented by this host target delegate."); + } }; /** @@ -116,6 +130,13 @@ class HostTargetController final { HostTargetDelegate& getDelegate(); + /** + * Instantiate a new NetworkIO with a scoped executor derived from the + * HostTarget's executor. Neither HostTarget nor HostTargetController + * retain a reference to the shared_ptr. + */ + std::shared_ptr createNetworkHandler(); + bool hasInstance() const; /** @@ -213,6 +234,12 @@ class JSINSPECTOR_EXPORT HostTarget */ void sendCommand(HostCommand command); + /** + * Instantiate a new NetworkIO with a scoped executor derived from the + * HostTarget's executor. HostTarget does not retain a reference. + */ + std::shared_ptr createNetworkHandler(); + private: /** * Constructs a new HostTarget. diff --git a/packages/react-native/ReactCommon/jsinspector-modern/InspectorInterfaces.h b/packages/react-native/ReactCommon/jsinspector-modern/InspectorInterfaces.h index 86c9a4b03b9d03..d30c858fb58975 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/InspectorInterfaces.h +++ b/packages/react-native/ReactCommon/jsinspector-modern/InspectorInterfaces.h @@ -35,7 +35,7 @@ class IDestructible { struct InspectorTargetCapabilities { bool nativePageReloads = false; - bool nativeSourceCodeFetching = false; + bool nativeSourceCodeFetching = true; bool prefersFuseboxFrontend = false; }; @@ -130,6 +130,19 @@ class JSINSPECTOR_EXPORT IInspector : public IDestructible { std::weak_ptr listener) = 0; }; +class NotImplementedException : public std::exception { + public: + explicit NotImplementedException(std::string message) + : msg_(std::move(message)) {} + + const char* what() const noexcept override { + return msg_.c_str(); + } + + private: + std::string msg_; +}; + /// getInspectorInstance retrieves the singleton inspector that tracks all /// debuggable pages in this process. extern IInspector& getInspectorInstance(); diff --git a/packages/react-native/ReactCommon/jsinspector-modern/NetworkIO.cpp b/packages/react-native/ReactCommon/jsinspector-modern/NetworkIO.cpp new file mode 100644 index 00000000000000..29ea97223f1977 --- /dev/null +++ b/packages/react-native/ReactCommon/jsinspector-modern/NetworkIO.cpp @@ -0,0 +1,212 @@ +/* + * 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 "NetworkIO.h" +#include + +namespace facebook::react::jsinspector_modern { + +static constexpr unsigned long DEFAULT_BYTES_PER_READ = + 1048576; // 1MB (Chrome v112 default) + +void NetworkIO::loadNetworkResource( + const LoadNetworkResourceParams& params, + NetworkRequestDelegate& delegate, + std::function callback) { + // This is an opaque identifier, but an incrementing integer in a string is + // consistent with Chrome. + StreamID streamId = std::to_string(nextStreamId_++); + auto stream = std::make_shared( + [streamId, callback, weakSelf = weak_from_this()]( + std::variant resultOrError) { + NetworkResource toReturn; + if (std::holds_alternative(resultOrError)) { + auto& result = std::get(resultOrError); + if (result.httpStatusCode >= 200 && result.httpStatusCode < 400) { + toReturn = NetworkResource{ + .success = true, + .stream = streamId, + .httpStatusCode = result.httpStatusCode, + .headers = result.headers}; + } else { + toReturn = NetworkResource{ + .success = false, + .httpStatusCode = result.httpStatusCode, + .headers = result.headers}; + } + } else { + auto& error = std::get(resultOrError); + toReturn = NetworkResource{.success = false, .netErrorName = error}; + } + if (!toReturn.success) { + if (auto strongSelf = weakSelf.lock()) { + strongSelf->cancelAndRemoveStreamIfExists(streamId); + } + } + callback(toReturn); + }); + stream->setExecutor(executorFromThis()); + streams_[streamId] = stream; + // Begin the network request on the platform, passing a shared_ptr to stream + // (a NetworkRequestListener) for platform code to call back into. + delegate.networkRequest(params.url, stream); +} + +void NetworkIO::readStream( + const ReadStreamParams& params, + std::function)> callback) { + auto it = streams_.find(params.handle); + if (it == streams_.end()) { + callback(IOReadError{"Stream not found with handle " + params.handle}); + } else { + it->second->read( + params.size ? *params.size : DEFAULT_BYTES_PER_READ, callback); + return; + } +} + +void NetworkIO::closeStream( + const StreamID& streamId, + std::function error)> callback) { + if (cancelAndRemoveStreamIfExists(streamId)) { + callback(std::nullopt); + } else { + callback("Stream not found: " + streamId); + } +} + +bool NetworkIO::cancelAndRemoveStreamIfExists(const StreamID& streamId) { + auto it = streams_.find(streamId); + if (it == streams_.end()) { + return false; + } else { + it->second->cancel(); + streams_.erase(it->first); + return true; + } +} + +NetworkIO::~NetworkIO() { + // Each stream is also retained by the delegate for as long as the request + // is in progress. Cancel the network operation (if implemented by the + // platform) to avoid unnecessary traffic and allow cleanup as soon as + // possible. + for (auto& [_, stream] : streams_) { + stream->cancel(); + } +} + +Stream::Stream( + std::function)> initCb) + : initCb_(std::move(initCb)) {} + +void Stream::onData(std::string_view data) { + executorFromThis()([copy = std::string(data)](Stream& self) { + self.data_ << copy; + self.bytesReceived_ += copy.length(); + self.processPending(); + }); +} + +void Stream::onHeaders(int httpStatusCode, const Headers& headers) { + executorFromThis()([=](Stream& self) { + // If we've already seen an error, the initial callback as already been + // called with it. + if (self.initCb_) { + self.initCb_(InitStreamResult{httpStatusCode, headers}); + self.initCb_ = nullptr; + } + }); +} + +void Stream::onError(const std::string& message) { + executorFromThis()([=](Stream& self) { + // Only call the error callback once. + if (!self.error_) { + self.error_ = message; + if (self.initCb_) { + self.initCb_(InitStreamError{message}); + self.initCb_ = nullptr; + } + } + self.processPending(); + }); +} + +void Stream::onEnd() { + executorFromThis()([](Stream& self) { + self.completed_ = true; + self.processPending(); + }); +} + +void Stream::setCancelFunction(std::function cancelFunction) { + cancelFunction_ = std::move(cancelFunction); +} + +// Must be called from the executor thread +void Stream::read( + unsigned long maxBytesToRead, + std::function)> callback) { + pendingReadRequests_.emplace_back(std::make_tuple(maxBytesToRead, callback)); + processPending(); +} + +void Stream::cancel() { + executorFromThis()([](Stream& self) { + if (self.cancelFunction_) { + (*self.cancelFunction_)(); + } + self.error_ = "Cancelled"; + if (self.initCb_) { + self.initCb_(InitStreamError{"Cancelled"}); + self.initCb_ = nullptr; + } + // Respond to any in-flight read requests with an error. + self.processPending(); + }); +} + +IOReadResult Stream::respond(unsigned long maxBytesToRead) { + std::vector buffer(maxBytesToRead); + data_.read(buffer.data(), maxBytesToRead); + auto bytesRead = data_.gcount(); + buffer.resize(bytesRead); + return IOReadResult{ + .data = folly::base64Encode(std::string_view(&buffer[0], buffer.size())), + .eof = bytesRead == 0 && completed_, + // TODO: Support UTF-8 string responses + .base64Encoded = true}; +} + +void Stream::processPending() { + // Go through each pending request in insertion order - execute the callback + // and remove it from pending if it can be satisfied. + for (auto it = pendingReadRequests_.begin(); + it != pendingReadRequests_.end();) { + auto maxBytesToRead = std::get<0>(*it); + auto callback = std::get<1>(*it); + + if (error_) { + callback(IOReadError{*error_}); + } else if ( + completed_ || (bytesReceived_ - data_.tellg() >= maxBytesToRead)) { + try { + callback(respond(maxBytesToRead)); + } catch (const std::runtime_error& error) { + callback(IOReadError{error.what()}); + } + } else { + // Not yet received enough data + ++it; + continue; + } + it = pendingReadRequests_.erase(it); + } +} + +} // namespace facebook::react::jsinspector_modern diff --git a/packages/react-native/ReactCommon/jsinspector-modern/NetworkIO.h b/packages/react-native/ReactCommon/jsinspector-modern/NetworkIO.h new file mode 100644 index 00000000000000..b586639d8d4a39 --- /dev/null +++ b/packages/react-native/ReactCommon/jsinspector-modern/NetworkIO.h @@ -0,0 +1,203 @@ +/* + * 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 "InspectorInterfaces.h" +#include "ScopedExecutor.h" + +#include +#include +#include +#include + +namespace facebook::react::jsinspector_modern { + +using StreamID = const std::string; +using Headers = std::map; +using IOReadError = const std::string; + +struct InitStreamResult { + int httpStatusCode; + const Headers& headers; +}; +using InitStreamError = const std::string; + +struct LoadNetworkResourceParams { + std::string url; +}; + +struct ReadStreamParams { + StreamID handle; + std::optional size; + std::optional offset; +}; + +struct NetworkResource { + bool success{}; + std::optional stream; + std::optional httpStatusCode; + std::optional netErrorName; + std::optional headers; +}; + +struct IOReadResult { + std::string data; + bool eof; + bool base64Encoded; +}; + +class NetworkRequestListener { + public: + NetworkRequestListener() = default; + NetworkRequestListener(const NetworkRequestListener&) = delete; + NetworkRequestListener& operator=(const NetworkRequestListener&) = delete; + NetworkRequestListener(NetworkRequestListener&&) noexcept = default; + NetworkRequestListener& operator=(NetworkRequestListener&&) noexcept = + default; + virtual ~NetworkRequestListener() = default; + virtual void onData(std::string_view data) = 0; + virtual void onHeaders(int httpStatusCode, const Headers& headers) = 0; + virtual void onError(const std::string& message) = 0; + virtual void onEnd() = 0; + virtual void setCancelFunction(std::function cancelFunction) = 0; +}; + +class NetworkRequestDelegate { + public: + NetworkRequestDelegate() = default; + NetworkRequestDelegate(const NetworkRequestDelegate&) = delete; + NetworkRequestDelegate& operator=(const NetworkRequestDelegate&) = delete; + NetworkRequestDelegate(NetworkRequestDelegate&&) noexcept = delete; + NetworkRequestDelegate& operator=(NetworkRequestDelegate&&) noexcept = delete; + virtual ~NetworkRequestDelegate() = default; + virtual void networkRequest( + const std::string& /*url*/, + std::shared_ptr /*listener*/) { + throw NotImplementedException( + "NetworkRequestDelegate.networkRequest is not implemented by this delegate."); + } +}; + +namespace { + +/** + * Private class owning state and implementing the listener for a particular + * request + * + * NetworkRequestListener overrides are thread safe, all other methods must be + * called from the same thread. + */ +class Stream : public NetworkRequestListener, + public EnableExecutorFromThis { + public: + explicit Stream( + std::function)> + initCb); + + /** + * NetworkIO-facing API. Enqueue a read request for up to maxBytesToRead + * bytes, starting from the end of the previous read. + */ + void read( + unsigned long maxBytesToRead, + std::function)> callback); + + /** + * NetworkIO-facing API. Call the platform-provided cancelFunction, if any, + * call the error callbacks of any in-flight read requests, and the initial + * error callback if it has not already fulfilled with success or error. + */ + void cancel(); + + /** + * Implementation of NetworkRequestListener, to be called by platform + * HostTargetDelegate. Any of these methods may be called from any thread. + */ + void onData(std::string_view data) override; + void onHeaders(int httpStatusCode, const Headers& headers) override; + void onError(const std::string& message) override; + void onEnd() override; + void setCancelFunction(std::function cancelFunction) override; + /* End NetworkRequestListener */ + + private: + void processPending(); + IOReadResult respond(unsigned long maxBytesToRead); + + bool completed_{false}; + std::optional error_; + std::stringstream data_; + unsigned long bytesReceived_{0}; + std::optional> cancelFunction_{std::nullopt}; + std::function)> initCb_; + std::vector)> /* read callback */>> + pendingReadRequests_; +}; + +} // namespace + +/** + * Provides the core implementation for handling CDP's + * Network.loadNetworkResource, IO.read and IO.close. + * + * Owns state of all in-progress and completed HTTP requests - ensure + * closeStream is used to free resources once consumed. + * + * Public methods must be called on the same thread. Callbacks will be called + * through the given executor. + */ +class NetworkIO : public EnableExecutorFromThis { + public: + ~NetworkIO(); + + /** + * Begin loading an HTTP resource, delegating platform-specific + * implementation. The callback will be called when either headers are + * received or an error occurs. If successful, the Stream ID provided to the + * callback can be used to read the contents of the resource via readStream(). + */ + void loadNetworkResource( + const LoadNetworkResourceParams& params, + NetworkRequestDelegate& delegate, + std::function callback); + + /** + * Close a given stream by its handle, call the callback with std::nullopt if + * a stream is found and destroyed, or with an error message if the stream is + * not found. Safely aborts any in-flight request. + */ + void closeStream( + const StreamID& streamId, + std::function error)> callback); + + /** + * Read a chunk of data from the stream, once enough has been downloaded, or + * call back with an error. + */ + void readStream( + const ReadStreamParams& params, + std::function result)> + callback); + + private: + /** + * Map of stream objects, which contain data received, accept read requests + * and listen for delegate events. Delegates have a shared_ptr to the Stream + * instance, but Streams should not live beyond the destruction of this + * NetworkIO instance. + */ + std::unordered_map> streams_; + unsigned long nextStreamId_{0}; + + bool cancelAndRemoveStreamIfExists(const StreamID& streamId); +}; + +} // namespace facebook::react::jsinspector_modern diff --git a/packages/react-native/ReactCommon/jsinspector-modern/tests/HostTargetTest.cpp b/packages/react-native/ReactCommon/jsinspector-modern/tests/HostTargetTest.cpp index d9a38c120d62de..b08addec2817c6 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/tests/HostTargetTest.cpp +++ b/packages/react-native/ReactCommon/jsinspector-modern/tests/HostTargetTest.cpp @@ -700,4 +700,283 @@ TEST_F(HostTargetTest, HostCommands) { page_->unregisterInstance(instanceTarget); } +TEST_F(HostTargetTest, NetworkLoadNetworkResourceSuccess) { + auto& instanceTarget = page_->registerInstance(instanceTargetDelegate_); + + connect(); + + InSequence s; + + std::shared_ptr listener; + EXPECT_CALL(hostTargetDelegate_, networkRequest(Eq("http://example.com"), _)) + .Times(1) + .WillOnce([&listener]( + const std::string& /*url*/, + std::shared_ptr listenerArg) { + // Capture the NetworkRequestLister to use later. + listener = listenerArg; + }) + .RetiresOnSaturation(); + + // Load the resource, expect a CDP response as soon as headers are received. + toPage_->sendMessage(R"({ + "id": 1, + "method": "Network.loadNetworkResource", + "params": { + "url": "http://example.com" + } + })"); + + EXPECT_CALL(fromPage(), onMessage(JsonEq(R"({ + "id": 1, + "result": { + "resource": { + "success": true, + "stream": "0", + "httpStatusCode": 200, + "headers": { + "x-test": "foo" + } + } + } + })"))); + + listener->onHeaders(200, Headers{{"x-test", "foo"}}); + + // Retrieve the first chunk of data. + toPage_->sendMessage(R"({ + "id": 2, + "method": "IO.read", + "params": { + "handle": "0", + "size": 8 + } + })"); + + EXPECT_CALL(fromPage(), onMessage(JsonEq(R"({ + "id": 2, + "result": { + "data": "SGVsbG8sIFc=", + "eof": false, + "base64Encoded": true + } + })"))); + listener->onData("Hello, World!"); + + // Retrieve the remaining data. + EXPECT_CALL(fromPage(), onMessage(JsonEq(R"({ + "id": 3, + "result": { + "data": "b3JsZCE=", + "eof": false, + "base64Encoded": true + } + })"))); + toPage_->sendMessage(R"({ + "id": 3, + "method": "IO.read", + "params": { + "handle": "0", + "size": 8 + } + })"); + + // No more data - expect empty payload with eof: true. + EXPECT_CALL(fromPage(), onMessage(JsonEq(R"({ + "id": 4, + "result": { + "data": "", + "eof": true, + "base64Encoded": true + } + })"))); + toPage_->sendMessage(R"({ + "id": 4, + "method": "IO.read", + "params": { + "handle": "0", + "size": 8 + } + })"); + listener->onEnd(); + + // Close the stream. + EXPECT_CALL(fromPage(), onMessage(JsonEq(R"({ + "id": 5, + "result": {} + })"))); + toPage_->sendMessage(R"({ + "id": 5, + "method": "IO.close", + "params": { + "handle": "0" + } + })"); + + page_->unregisterInstance(instanceTarget); +} + +TEST_F(HostTargetTest, NetworkLoadNetworkResourceStreamInterrupted) { + auto& instanceTarget = page_->registerInstance(instanceTargetDelegate_); + + connect(); + + InSequence s; + + std::shared_ptr listener; + EXPECT_CALL(hostTargetDelegate_, networkRequest(Eq("http://example.com"), _)) + .Times(1) + .WillOnce([&listener]( + const std::string& /*url*/, + std::shared_ptr listenerArg) { + listener = listenerArg; + }) + .RetiresOnSaturation(); + + // Load the resource, receiving headers succesfully. + toPage_->sendMessage(R"({ + "id": 1, + "method": "Network.loadNetworkResource", + "params": { + "url": "http://example.com" + } + })"); + + EXPECT_CALL(fromPage(), onMessage(JsonEq(R"({ + "id": 1, + "result": { + "resource": { + "success": true, + "stream": "0", + "httpStatusCode": 200, + "headers": { + "x-test": "foo" + } + } + } + })"))); + + listener->onHeaders(200, Headers{{"x-test", "foo"}}); + + // Retrieve the first chunk of data. + toPage_->sendMessage(R"({ + "id": 2, + "method": "IO.read", + "params": { + "handle": "0", + "size": 20 + } + })"); + + EXPECT_CALL(fromPage(), onMessage(JsonEq(R"({ + "id": 2, + "result": { + "data": "VGhlIG1lYW5pbmcgb2YgbGlmZSA=", + "eof": false, + "base64Encoded": true + } + })"))); + listener->onData("The meaning of life is..."); + + // Simulate an error mid-stream, expect in-flight IO.reads to return a CDP + // error. + toPage_->sendMessage(R"({ + "id": 3, + "method": "IO.read", + "params": { + "handle": "0", + "size": 20 + } + })"); + + EXPECT_CALL(fromPage(), onMessage(JsonEq(R"({ + "id": 3, + "error": { + "code": -32603, + "message": "Connection lost" + } + })"))); + listener->onError("Connection lost"); + + // IO.close should be a successful no-op after an error. + EXPECT_CALL(fromPage(), onMessage(JsonEq(R"({ + "id": 4, + "result": {} + })"))); + toPage_->sendMessage(R"({ + "id": 4, + "method": "IO.close", + "params": { + "handle": "0" + } + })"); + + page_->unregisterInstance(instanceTarget); +} + +TEST_F(HostTargetTest, NetworkLoadNetworkResource404) { + auto& instanceTarget = page_->registerInstance(instanceTargetDelegate_); + + connect(); + + InSequence s; + + std::shared_ptr listener; + EXPECT_CALL( + hostTargetDelegate_, networkRequest(Eq("http://notexists.com"), _)) + .Times(1) + .WillOnce([&listener]( + const std::string& /*url*/, + std::shared_ptr listenerArg) { + listener = std::move(listenerArg); + }) + .RetiresOnSaturation(); + + // A 404 response should trigger a CDP result with success: false, including + // the status code, headers, but *no* stream handle. + EXPECT_CALL(fromPage(), onMessage(JsonEq(R"({ + "id": 1, + "result": { + "resource": { + "success": false, + "httpStatusCode": 404, + "headers": { + "x-test": "foo" + } + } + } + })"))); + + toPage_->sendMessage(R"({ + "id": 1, + "method": "Network.loadNetworkResource", + "params": { + "url": "http://notexists.com" + } + })"); + + listener->onHeaders(404, Headers{{"x-test", "foo"}}); + + // Assuming a successful request would have assigned handle "0", verify that + // handle has *not* been assigned. + EXPECT_CALL(fromPage(), onMessage(JsonEq(R"({ + "id": 2, + "error": { + "code": -32603, + "message": "Stream not found with handle 0" + } + })"))); + + toPage_->sendMessage(R"({ + "id": 2, + "method": "IO.read", + "params": { + "handle": "0", + "size": 20 + } + })"); + + page_->unregisterInstance(instanceTarget); +} + } // namespace facebook::react::jsinspector_modern diff --git a/packages/react-native/ReactCommon/jsinspector-modern/tests/InspectorMocks.h b/packages/react-native/ReactCommon/jsinspector-modern/tests/InspectorMocks.h index abc1acfe0a3b07..678abed310aa4e 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/tests/InspectorMocks.h +++ b/packages/react-native/ReactCommon/jsinspector-modern/tests/InspectorMocks.h @@ -124,6 +124,12 @@ class MockHostTargetDelegate : public HostTargetDelegate { onSetPausedInDebuggerMessage, (const OverlaySetPausedInDebuggerMessageRequest& request), (override)); + MOCK_METHOD( + void, + networkRequest, + (const std::string& url, + std::shared_ptr listener), + (override)); }; class MockInstanceTargetDelegate : public InstanceTargetDelegate {};