diff --git a/packages/react-native/ReactCommon/jsinspector-modern/HostAgent.cpp b/packages/react-native/ReactCommon/jsinspector-modern/HostAgent.cpp index 260c5aadd6f..7d0cb50d9ab 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/HostAgent.cpp +++ b/packages/react-native/ReactCommon/jsinspector-modern/HostAgent.cpp @@ -30,11 +30,13 @@ HostAgent::HostAgent( FrontendChannel frontendChannel, HostTargetController& targetController, HostTargetMetadata hostMetadata, - SessionState& sessionState) + SessionState& sessionState, + VoidExecutor executor) : frontendChannel_(frontendChannel), targetController_(targetController), hostMetadata_(std::move(hostMetadata)), - sessionState_(sessionState) {} + sessionState_(sessionState), + networkIOAgent_(NetworkIOAgent(frontendChannel, executor)) {} void HostAgent::handleRequest(const cdp::PreparsedRequest& req) { bool shouldSendOKResponse = false; @@ -182,6 +184,12 @@ void HostAgent::handleRequest(const cdp::PreparsedRequest& req) { frontendChannel_(cdp::jsonNotification( "Tracing.tracingComplete", folly::dynamic::object("dataLossOccurred", false))); + shouldSendOKResponse = true; + isFinishedHandlingRequest = true; + } + + if (!isFinishedHandlingRequest && + networkIOAgent_.handleRequest(req, targetController_.getDelegate())) { return; } @@ -195,10 +203,7 @@ 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); } HostAgent::~HostAgent() { diff --git a/packages/react-native/ReactCommon/jsinspector-modern/HostAgent.h b/packages/react-native/ReactCommon/jsinspector-modern/HostAgent.h index 903a4be9e0f..8fea9da662e 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 "NetworkIOAgent.h" #include "SessionState.h" #include @@ -39,12 +41,14 @@ class HostAgent final { * HostTargetDelegate and underlying HostTarget both outlive the agent. * \param hostMetadata Metadata about the host that created this agent. * \param sessionState The state of the session that created this agent. + * \param exector A void executor to be used by async-aware handlers. */ HostAgent( FrontendChannel frontendChannel, HostTargetController& targetController, HostTargetMetadata hostMetadata, - SessionState& sessionState); + SessionState& sessionState, + VoidExecutor executor); HostAgent(const HostAgent&) = delete; HostAgent(HostAgent&&) = delete; @@ -104,6 +108,8 @@ class HostAgent final { * during handleRequest and other method calls on the same thread. */ SessionState& sessionState_; + + NetworkIOAgent networkIOAgent_; }; } // 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 a29de13480e..8186ac65ba2 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/HostTarget.cpp +++ b/packages/react-native/ReactCommon/jsinspector-modern/HostTarget.cpp @@ -29,7 +29,8 @@ class HostTargetSession { explicit HostTargetSession( std::unique_ptr remote, HostTargetController& targetController, - HostTargetMetadata hostMetadata) + HostTargetMetadata hostMetadata, + VoidExecutor executor) : remote_(std::make_shared(std::move(remote))), frontendChannel_( [remoteWeak = std::weak_ptr(remote_)](std::string_view message) { @@ -41,7 +42,8 @@ class HostTargetSession { frontendChannel_, targetController, std::move(hostMetadata), - state_) {} + state_, + executor) {} /** * Called by CallbackLocalConnection to send a message to this Session's @@ -62,15 +64,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; + } } /** @@ -148,7 +157,10 @@ HostTarget::HostTarget(HostTargetDelegate& delegate) std::unique_ptr HostTarget::connect( std::unique_ptr connectionToFrontend) { auto session = std::make_shared( - std::move(connectionToFrontend), controller_, delegate_.getMetadata()); + std::move(connectionToFrontend), + controller_, + delegate_.getMetadata(), + makeVoidExecutor(executorFromThis())); session->setCurrentInstance(currentInstance_.get()); sessions_.insert(std::weak_ptr(session)); return std::make_unique( diff --git a/packages/react-native/ReactCommon/jsinspector-modern/HostTarget.h b/packages/react-native/ReactCommon/jsinspector-modern/HostTarget.h index 880b3df759b..c7798760ebf 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 "NetworkIOAgent.h" #include "ScopedExecutor.h" #include "WeakList.h" @@ -50,13 +51,13 @@ struct HostTargetMetadata { * React Native platform needs to implement in order to integrate with the * debugging stack. */ -class HostTargetDelegate { +class HostTargetDelegate : public LoadNetworkResourceDelegate { 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. @@ -92,7 +93,7 @@ class HostTargetDelegate { } }; - virtual ~HostTargetDelegate(); + virtual ~HostTargetDelegate() override; /** * Returns a metadata object describing the host. This is called on an @@ -119,6 +120,19 @@ class HostTargetDelegate { */ virtual void onSetPausedInDebuggerMessage( const OverlaySetPausedInDebuggerMessageRequest& request) = 0; + + /** + * Called by NetworkIOAgent 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 loadNetworkResource( + const LoadNetworkResourceRequest& /*params*/, + ScopedExecutor /*executor*/) override { + throw NotImplementedException( + "LoadNetworkResourceDelegate.loadNetworkResource is not implemented by this host target delegate."); + } }; /** diff --git a/packages/react-native/ReactCommon/jsinspector-modern/InspectorInterfaces.h b/packages/react-native/ReactCommon/jsinspector-modern/InspectorInterfaces.h index 68ef76de1f3..b8fac7934d5 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/InspectorInterfaces.h +++ b/packages/react-native/ReactCommon/jsinspector-modern/InspectorInterfaces.h @@ -134,6 +134,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/NetworkIOAgent.cpp b/packages/react-native/ReactCommon/jsinspector-modern/NetworkIOAgent.cpp new file mode 100644 index 00000000000..eb6a45c3170 --- /dev/null +++ b/packages/react-native/ReactCommon/jsinspector-modern/NetworkIOAgent.cpp @@ -0,0 +1,387 @@ +/* + * 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 "NetworkIOAgent.h" +#include + +namespace facebook::react::jsinspector_modern { + +static constexpr long DEFAULT_BYTES_PER_READ = + 1048576; // 1MB (Chrome v112 default) + +namespace { + +struct InitStreamResult { + int httpStatusCode; + Headers headers; + std::shared_ptr stream; +}; +using InitStreamError = const std::string; + +using StreamInitCallback = + std::function)>; +using IOReadCallback = + std::function)>; + +/** + * 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: + Stream(const Stream& other) = delete; + Stream& operator=(const Stream& other) = delete; + Stream(Stream&& other) = default; + Stream& operator=(Stream&& other) = default; + + /** + * Factory method to create a Stream with a callback for the initial result + * of a network request. + * \param executor An executor on which all processing of callbacks from + * the platform will be performed, and on which the passed callback will be + * called. + * \param initCb Will be called once either on receipt of HTTP headers or + * any prior error, using the given executor. + */ + static std::shared_ptr create( + VoidExecutor executor, + StreamInitCallback initCb) { + std::shared_ptr stream{new Stream(initCb)}; + stream->setExecutor(executor); + return stream; + } + + /** + * Agent-facing API. Enqueue a read request for up to maxBytesToRead + * bytes, starting from the end of the previous read. + * \param maxBytesToRead The maximum number of bytes to read from the + * source stream. + * \param callback Will be called using the executor passed to create() + * with the result of the read, or an error string. + */ + void read(long maxBytesToRead, const IOReadCallback& callback) { + pendingReadRequests_.emplace_back( + std::make_tuple(maxBytesToRead, callback)); + processPending(); + } + + /** + * Agent-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() { + if (cancelFunction_) { + (*cancelFunction_)(); + } + error_ = "Cancelled"; + if (initCb_) { + auto cb = std::move(initCb_); + (*cb)(InitStreamError{"Cancelled"}); + } + // Respond to any in-flight read requests with an error. + processPending(); + } + + /** + * Begin 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 { + data_ << data; + bytesReceived_ += data.length(); + processPending(); + } + + void onHeaders(int httpStatusCode, const Headers& headers) override { + // If we've already seen an error, the initial callback as already been + // called with it. + if (initCb_) { + auto cb = std::move(initCb_); + (*cb)( + InitStreamResult{httpStatusCode, headers, this->shared_from_this()}); + } + } + + void onError(const std::string& message) override { + // Only call the error callback once. + if (!error_) { + error_ = message; + if (initCb_) { + auto cb = std::move(initCb_); + (*cb)(InitStreamError{message}); + } + } + processPending(); + } + + void onCompletion() override { + completed_ = true; + processPending(); + } + + void setCancelFunction(std::function cancelFunction) override { + cancelFunction_ = std::move(cancelFunction); + } + + ~Stream() override { + // Cancel any incoming request, if the platform has provided a cancel + // callback. + if (cancelFunction_) { + (*cancelFunction_)(); + } + } + + /* End NetworkRequestListener */ + + private: + /** + * Private constructor. The caller must call setExecutor immediately + * afterwards. + */ + explicit Stream(const StreamInitCallback& initCb) + : initCb_(std::make_unique(initCb)) {} + + void 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); + } + } + + IOReadResult respond(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.data(), buffer.size())), + .eof = bytesRead == 0 && completed_, + // TODO: Support UTF-8 string responses + .base64Encoded = true}; + } + + bool completed_{false}; + std::optional error_; + std::stringstream data_; + long bytesReceived_{0}; + std::optional> cancelFunction_{std::nullopt}; + std::unique_ptr initCb_; + std::vector> + pendingReadRequests_; +}; +} // namespace + +bool NetworkIOAgent::handleRequest( + const cdp::PreparsedRequest& req, + LoadNetworkResourceDelegate& delegate) { + if (req.method == "Network.loadNetworkResource") { + handleLoadNetworkResource(req, delegate); + return true; + } else if (req.method == "IO.read") { + handleIoRead(req); + return true; + } else if (req.method == "IO.close") { + handleIoClose(req); + return true; + } + return false; +} + +void NetworkIOAgent::handleLoadNetworkResource( + const cdp::PreparsedRequest& req, + LoadNetworkResourceDelegate& delegate) { + long long requestId = req.id; + + LoadNetworkResourceRequest params; + + 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; + } else { + params.url = req.params.at("url").asString(); + } + + // This is an opaque identifier, but an incrementing integer in a string is + // consistent with Chrome. + StreamID streamId = std::to_string(nextStreamId_++); + + auto stream = Stream::create( + executor_, + [streamId, + requestId, + frontendChannel = frontendChannel_, + streamsWeak = std::weak_ptr(streams_)](auto resultOrError) { + NetworkResource resource; + std::string cdpError; + if (auto* error = std::get_if(&resultOrError)) { + resource = NetworkResource{.success = false, .netErrorName = *error}; + } else if ( + auto* result = std::get_if(&resultOrError)) { + if (result->httpStatusCode >= 200 && result->httpStatusCode < 300) { + resource = NetworkResource{ + .success = true, + .stream = streamId, + .httpStatusCode = result->httpStatusCode, + .headers = result->headers}; + } else if (result->httpStatusCode >= 400) { + resource = NetworkResource{ + .success = false, + .httpStatusCode = result->httpStatusCode, + .headers = result->headers}; + } else { + // We can't deal with <200 or 3xx reponses here (though they may be + // transparently handled by the delegate). Return a CDP error (not + // an unsuccesful resource) to the frontend so that it falls back to + // a direct fetch. + cdpError = "Handling of status " + + std::to_string(result->httpStatusCode) + " not implemented."; + } + } else { + assert(false && "Unhandled IO init result type"); + } + if (cdpError.length() > 0 || !resource.success) { + // Release and destroy the stream after the calling executor returns. + // ~Stream will handle cancelling any download in progress. + if (auto streams = streamsWeak.lock()) { + streams->erase(streamId); + } + } + frontendChannel( + cdpError.length() + ? cdp::jsonError( + requestId, cdp::ErrorCode::InternalError, cdpError) + : cdp::jsonResult( + requestId, + folly::dynamic::object( + "resource", resource.toDynamic()))); + }); + + // Begin the network request on the platform, passing an executor scoped to + // a Stream (a NetworkRequestListener), which the implementation will call + // back into. + delegate.loadNetworkResource(params, stream->executorFromThis()); + + // Retain the stream only if delegate.loadNetworkResource does not throw. + streams_->emplace(streamId, stream); +} + +void NetworkIOAgent::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(); + } + + auto streamId = req.params.at("handle").asString(); + auto it = streams_->find(streamId); + if (it == streams_->end()) { + frontendChannel_(cdp::jsonError( + requestId, + cdp::ErrorCode::InternalError, + "Stream not found with handle " + streamId)); + return; + } else { + it->second->read( + size ? *size : DEFAULT_BYTES_PER_READ, + [requestId, frontendChannel = frontendChannel_](auto resultOrError) { + if (auto* error = std::get_if(&resultOrError)) { + frontendChannel(cdp::jsonError( + requestId, cdp::ErrorCode::InternalError, *error)); + } else if (auto* result = std::get_if(&resultOrError)) { + frontendChannel(cdp::jsonResult(requestId, result->toDynamic())); + } else { + assert(false && "Unhandled IO read result type"); + } + }); + return; + } +} + +void NetworkIOAgent::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; + } + auto streamId = req.params.at("handle").asString(); + + auto it = streams_->find(streamId); + if (it == streams_->end()) { + frontendChannel_(cdp::jsonError( + requestId, + cdp::ErrorCode::InternalError, + "Stream not found: " + streamId)); + } else { + it->second->cancel(); + streams_->erase(it->first); + frontendChannel_(cdp::jsonResult(requestId)); + } +} + +} // namespace facebook::react::jsinspector_modern diff --git a/packages/react-native/ReactCommon/jsinspector-modern/NetworkIOAgent.h b/packages/react-native/ReactCommon/jsinspector-modern/NetworkIOAgent.h new file mode 100644 index 00000000000..ebc58d44a37 --- /dev/null +++ b/packages/react-native/ReactCommon/jsinspector-modern/NetworkIOAgent.h @@ -0,0 +1,268 @@ +/* + * 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 "CdpJson.h" +#include "InspectorInterfaces.h" +#include "ScopedExecutor.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace facebook::react::jsinspector_modern { + +using StreamID = const std::string; +using Headers = std::map; +using IOReadError = const std::string; + +namespace { +class Stream; // Defined in NetworkIOAgent.cpp +using StreamsMap = std::unordered_map>; +} // namespace + +struct LoadNetworkResourceRequest { + 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; + folly::dynamic toDynamic() const { + auto dynamicResource = folly::dynamic::object("success", success); + + if (success) { // stream IFF successful + assert(stream); + dynamicResource("stream", *stream); + } + + if (netErrorName) { // Only if unsuccessful + assert(!success); + dynamicResource("netErrorName", *netErrorName); + } + + if (httpStatusCode) { // Guaranteed if successful + dynamicResource("httpStatusCode", *httpStatusCode); + } else { + assert(!success); + } + + if (headers) { // Guaranteed if successful + auto dynamicHeaders = folly::dynamic::object(); + for (const auto& pair : *headers) { + dynamicHeaders(pair.first, pair.second); + } + dynamicResource("headers", std::move(dynamicHeaders)); + } else { + assert(!success); + } + return dynamicResource; + } +}; + +struct IOReadResult { + std::string data; + bool eof; + bool base64Encoded; + folly::dynamic toDynamic() const { + auto obj = folly::dynamic::object("data", data); + obj("eof", eof); + obj("base64Encoded", base64Encoded); + return obj; + } +}; + +/** + * Passed to `loadNetworkResource`, provides callbacks for processing incoming + * data and other events. + */ +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; + + /** + * To be called by the delegate on receipt of response headers, including + * on "unsuccessful" status codes. + * + * \param httpStatusCode The HTTP status code received. + * \param headers Response headers as an unordered_map. + */ + virtual void onHeaders(int httpStatusCode, const Headers& headers) = 0; + + /** + * To be called by the delegate on receipt of data chunks. + * \param data The data received. + */ + virtual void onData(std::string_view data) = 0; + + /** + * To be called by the delegate on any error with the request, either before + * headers are received or for a subsequent interrupion. + * + * \param message A short, human-readable message, which may be forwarded to + * the CDP client either in the `loadNetworkResource` response (if headers + * were not yet received), or as a CDP error in response to a subsequent + * `IO.read`. + */ + virtual void onError(const std::string& message) = 0; + + /** + * To be called by the delegate on successful completion of the request. + * Delegates must call *either* onCompletion() or onError() exactly once. + */ + virtual void onCompletion() = 0; + + /** + * Optionally (preferably) used to give NetworkIOAgent + a way to cancel an + * in-progress download. + * + * \param cancelFunction A function that can be called to cancel a download, + * may be called before or after the download is complete. + */ + virtual void setCancelFunction(std::function cancelFunction) = 0; +}; + +/** + * Implemented by the HostTargetDelegate per-platform to perform network + * requests. + */ +class LoadNetworkResourceDelegate { + public: + LoadNetworkResourceDelegate() = default; + LoadNetworkResourceDelegate(const LoadNetworkResourceDelegate&) = delete; + LoadNetworkResourceDelegate& operator=(const LoadNetworkResourceDelegate&) = + delete; + LoadNetworkResourceDelegate(LoadNetworkResourceDelegate&&) noexcept = delete; + LoadNetworkResourceDelegate& operator=( + LoadNetworkResourceDelegate&&) noexcept = delete; + virtual ~LoadNetworkResourceDelegate() = default; + + /** + * Called by NetworkIOAgent 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 any thread) on receipt of headers, data chunks, + * and errors. + * + * \param params A LoadNetworkResourceRequest, including the url. + * \param listener The listener to call on headers, data chunks, and errors. + * Implementations must ensure that they retain a shared_ptr to listener for + * as long as its callbacks may be called, and should release it once the + * network request is complete or cancelled. Implementations *should* call + * listener->setCancelFunction() to provide a lambda that can be called to + * abort any in-flight network operation that is no longer needed. + */ + virtual void loadNetworkResource( + const LoadNetworkResourceRequest& /*params*/, + ScopedExecutor /*executor*/) = 0; +}; + +/** + * Provides an agent for handling CDP's Network.loadNetworkResource, IO.read and + * IO.close. + * + * Owns state of all in-progress and completed HTTP requests - ensure + * IO.close is used to free resources once consumed. + * + * Public methods must be called the same thread as the given executor. + */ +class NetworkIOAgent { + public: + /** + * \param frontendChannel A channel used to send responses to the + * frontend. + * \param executor An executor used for any callbacks provided, and for + * processing incoming data or other events from network operations. + */ + NetworkIOAgent(FrontendChannel frontendChannel, VoidExecutor executor) + : frontendChannel_(frontendChannel), + executor_(executor), + streams_(std::make_shared()) {} + + /** + * Handle a CDP request. The response will be sent over the provided + * \c FrontendChannel synchronously or asynchronously. + * \param req The parsed request. + */ + bool handleRequest( + const cdp::PreparsedRequest& req, + LoadNetworkResourceDelegate& delegate); + + private: + /** + * A channel used to send responses and events to the frontend. + */ + FrontendChannel frontendChannel_; + + /** + * An executor used to create NetworkRequestListener-scoped executors for the + * delegate. + */ + VoidExecutor executor_; + + /** + * Map of stream objects, which contain data received, accept read requests + * and listen for delegate events. Delegates have a scoped executor for Stream + * instances, but Streams will not live beyond the destruction of this + * NetworkIOAgent instance + executor scope. + * + * This is a shared_ptr so that we may capture a weak_ptr in our + * Stream::create callback without creating a cycle. + */ + std::shared_ptr streams_; + + /** + * Stream IDs are strings of an incrementing integer, unique within each + * NewtworkIOAgent instance. This stores the next one to use. + */ + unsigned long nextStreamId_{0}; + + /** + * Begin loading an HTTP resource, delegating platform-specific + * implementation, responding to the frontend on headers received or on error. + * Does not catch exceptions thrown by the delegate (such as + * NotImplementedException). + */ + void handleLoadNetworkResource( + const cdp::PreparsedRequest& req, + LoadNetworkResourceDelegate& delegate); + + /** + * Handle an IO.read CDP request. Emit a chunk of data from the stream, once + * enough has been downloaded, or report an error. + */ + void handleIoRead(const cdp::PreparsedRequest& req); + + /** + * Handle an IO.close CDP request. Safely aborts any in-flight request. + * Reports CDP ok if the stream is found, or a CDP error if not. + */ + void handleIoClose(const cdp::PreparsedRequest& req); +}; + +} // 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 869c9f1de25..b99aca83bf0 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/tests/HostTargetTest.cpp +++ b/packages/react-native/ReactCommon/jsinspector-modern/tests/HostTargetTest.cpp @@ -698,4 +698,595 @@ TEST_F(HostTargetTest, HostCommands) { page_->unregisterInstance(instanceTarget); } +TEST_F(HostTargetTest, NetworkLoadNetworkResourceSuccess) { + connect(); + + InSequence s; + + ScopedExecutor executor; + EXPECT_CALL( + hostTargetDelegate_, + loadNetworkResource( + Field(&LoadNetworkResourceRequest::url, "http://example.com"), _)) + .Times(1) + .WillOnce([&executor]( + const LoadNetworkResourceRequest& /*params*/, + ScopedExecutor executorArg) { + // Capture the ScopedExecutor to use later. + executor = std::move(executorArg); + }) + .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", + "Content-Type": "text/plain" + } + } + } + })"))); + + executor([](NetworkRequestListener& listener) { + listener.onHeaders( + 200, Headers{{"x-test", "foo"}, {"Content-Type", "text/plain"}}); + }); + + // 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 + } + })"))); + + executor([](NetworkRequestListener& listener) { + 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 + } + })"); + + executor([](NetworkRequestListener& listener) { listener.onCompletion(); }); + + // Close the stream. + EXPECT_CALL(fromPage(), onMessage(JsonEq(R"({ + "id": 5, + "result": {} + })"))); + toPage_->sendMessage(R"({ + "id": 5, + "method": "IO.close", + "params": { + "handle": "0" + } + })"); +} + +TEST_F(HostTargetTest, NetworkLoadNetworkResourceStreamInterrupted) { + connect(); + + InSequence s; + + ScopedExecutor executor; + EXPECT_CALL( + hostTargetDelegate_, + loadNetworkResource( + Field(&LoadNetworkResourceRequest::url, "http://example.com"), _)) + .Times(1) + .WillOnce([&executor]( + const LoadNetworkResourceRequest& /*params*/, + ScopedExecutor executorArg) { + // Capture the ScopedExecutor to use later. + executor = std::move(executorArg); + }) + .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" + } + } + } + })"))); + + executor([](NetworkRequestListener& listener) { + 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 + } + })"))); + executor([](NetworkRequestListener& listener) { + 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" + } + })"))); + executor([](NetworkRequestListener& listener) { + 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" + } + })"); +} + +TEST_F(HostTargetTest, NetworkLoadNetworkResource404) { + connect(); + + InSequence s; + + ScopedExecutor executor; + EXPECT_CALL( + hostTargetDelegate_, + loadNetworkResource( + Field(&LoadNetworkResourceRequest::url, "http://example.com/404"), _)) + .Times(1) + .WillOnce([&executor]( + const LoadNetworkResourceRequest& /*params*/, + ScopedExecutor executorArg) { + // Capture the ScopedExecutor to use later. + executor = std::move(executorArg); + }) + .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://example.com/404" + } + })"); + + executor([](NetworkRequestListener& listener) { + 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 + } + })"); +} + +TEST_F(HostTargetTest, NetworkLoadNetworkResourceInitialNetError) { + connect(); + + InSequence s; + + ScopedExecutor executor; + EXPECT_CALL( + hostTargetDelegate_, + loadNetworkResource( + Field(&LoadNetworkResourceRequest::url, "http://baddomain.com"), _)) + .Times(1) + .WillOnce([&executor]( + const LoadNetworkResourceRequest& /*params*/, + ScopedExecutor executorArg) { + // Capture the ScopedExecutor to use later. + executor = std::move(executorArg); + }) + .RetiresOnSaturation(); + + // Load the resource, expect a CDP resonse with no headers or status code, + // but with success: false and a netErrorName + toPage_->sendMessage(R"({ + "id": 1, + "method": "Network.loadNetworkResource", + "params": { + "url": "http://baddomain.com" + } + })"); + + EXPECT_CALL(fromPage(), onMessage(JsonEq(R"({ + "id": 1, + "result": { + "resource": { + "success": false, + "netErrorName": "Arbitrary error string" + } + } + })"))); + + executor([](NetworkRequestListener& listener) { + listener.onError("Arbitrary error string"); + }); +} + +TEST_F(HostTargetTest, NetworkLoadNetworkResourceStreamClosed) { + connect(); + + InSequence s; + + ScopedExecutor executor; + EXPECT_CALL( + hostTargetDelegate_, + loadNetworkResource( + Field(&LoadNetworkResourceRequest::url, "http://example.com"), _)) + .Times(1) + .WillOnce([&executor]( + const LoadNetworkResourceRequest& /*params*/, + ScopedExecutor executorArg) { + // Capture the ScopedExecutor to use later. + executor = std::move(executorArg); + }) + .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" + } + } + } + })"))); + + bool cancelFunctionCalled = false; + executor([&cancelFunctionCalled](NetworkRequestListener& listener) { + listener.setCancelFunction( + [&cancelFunctionCalled]() { cancelFunctionCalled = true; }); + + 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 + } + })"))); + executor([](NetworkRequestListener& listener) { + listener.onData("The meaning of life is..."); + }); + + EXPECT_FALSE(cancelFunctionCalled); + + // Simulate the client closing the stream while data is still incoming. + + EXPECT_CALL(fromPage(), onMessage(JsonEq(R"({ + "id": 3, + "result": {} + })"))); + + toPage_->sendMessage(R"({ + "id": 3, + "method": "IO.close", + "params": { + "handle": "0" + } + })"); + + EXPECT_TRUE(cancelFunctionCalled); +} + +TEST_F(HostTargetTest, NetworkLoadNetworkResourceAgentDisconnect) { + connect(); + + InSequence s; + + ScopedExecutor executor; + EXPECT_CALL(hostTargetDelegate_, loadNetworkResource(_, _)) + .Times(1) + .WillOnce([&executor]( + const LoadNetworkResourceRequest& /*params*/, + ScopedExecutor executorArg) { + // Capture the ScopedExecutor to use later. + executor = std::move(executorArg); + }) + .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" + } + } + } + })"))); + + bool cancelFunctionCalled = false; + executor([&cancelFunctionCalled](NetworkRequestListener& listener) { + listener.setCancelFunction( + [&cancelFunctionCalled]() { cancelFunctionCalled = true; }); + + listener.onHeaders(200, Headers{{"x-test", "foo"}}); + }); + + EXPECT_FALSE(cancelFunctionCalled); + + // Simulate the frontend disconnecting while data is still incoming. + toPage_->disconnect(); + + // Expect the destruction of the agent to notify the platform implementation + // that it may cancel any download. + EXPECT_TRUE(cancelFunctionCalled); + + // The host may still hold a scoped executor, but our listener has now been + // destroyed because it was owned by the (disconnected) agent, so we expect + // a late executor call to be a) safe and b) never execute. + bool callbackCalledAfterDisconnect = false; + executor( + [&callbackCalledAfterDisconnect](NetworkRequestListener& /*listener*/) { + callbackCalledAfterDisconnect = true; + }); + EXPECT_FALSE(callbackCalledAfterDisconnect); +} + +TEST_F(HostTargetTest, NetworkLoadNetworkResourceNotImplementedByDelegate) { + connect(); + + InSequence s; + + EXPECT_CALL( + hostTargetDelegate_, + loadNetworkResource( + Field(&LoadNetworkResourceRequest::url, "http://example.com"), _)) + .Times(1) + .WillOnce([](const LoadNetworkResourceRequest& /*params*/, + ScopedExecutor /*executor*/) { + throw NotImplementedException( + "This delegate does not implement loadNetworkResource."); + }) + .RetiresOnSaturation(); + + // The delegate's loadNetworkResource may throw immediately - verify this is + // handled and that we clean up. + EXPECT_CALL(fromPage(), onMessage(JsonEq(R"({ + "id": 1, + "error": { + "code": -32601, + "message": "This delegate does not implement loadNetworkResource." + } + })"))); + + toPage_->sendMessage(R"({ + "id": 1, + "method": "Network.loadNetworkResource", + "params": { + "url": "http://example.com" + } + })"); + + // Check no stream is retained + EXPECT_CALL(fromPage(), onMessage(JsonEq(R"({ + "id": 2, + "error": { + "code": -32603, + "message": "Stream not found: 0" + } + })"))); + + toPage_->sendMessage(R"({ + "id": 2, + "method": "IO.close", + "params": { + "handle": "0" + } + })"); +} + +TEST_F(HostTargetTest, NetworkLoadNetworkResource3xx) { + connect(); + + InSequence s; + + ScopedExecutor executor; + EXPECT_CALL( + hostTargetDelegate_, + loadNetworkResource( + Field(&LoadNetworkResourceRequest::url, "http://example.com/3xx"), _)) + .Times(1) + .WillOnce([&executor]( + const LoadNetworkResourceRequest& /*params*/, + ScopedExecutor executorArg) { + // Capture the ScopedExecutor to use later. + executor = std::move(executorArg); + }) + .RetiresOnSaturation(); + + // We don't support 3xx responses, and treat them as a CDP error (as if not + // implemented so that the frontend may fall back. + toPage_->sendMessage(R"({ + "id": 1, + "method": "Network.loadNetworkResource", + "params": { + "url": "http://example.com/3xx" + } + })"); + + EXPECT_CALL(fromPage(), onMessage(JsonEq(R"({ + "id": 1, + "error": { + "code": -32603, + "message": "Handling of status 301 not implemented." + } + })"))); + + executor([](NetworkRequestListener& listener) { + listener.onHeaders(301, Headers{{"Location", "/new"}}); + }); +} + } // 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 83c73de6a4a..63f418809c7 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/tests/InspectorMocks.h +++ b/packages/react-native/ReactCommon/jsinspector-modern/tests/InspectorMocks.h @@ -127,6 +127,12 @@ class MockHostTargetDelegate : public HostTargetDelegate { onSetPausedInDebuggerMessage, (const OverlaySetPausedInDebuggerMessageRequest& request), (override)); + MOCK_METHOD( + void, + loadNetworkResource, + (const LoadNetworkResourceRequest& params, + ScopedExecutor executor), + (override)); }; class MockInstanceTargetDelegate : public InstanceTargetDelegate {};