diff --git a/CMakeLists.txt b/CMakeLists.txt index 68016730aa..d07fb5caa7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -447,23 +447,40 @@ if(DESKTOP) set(CURL_DISABLE_INSTALL ON) if(WIN32) - # Use Windows native Schannel/SSPI for TLS instead of OpenSSL/BoringSSL. + # Use Windows native Schannel/SSPI (Crypt32) for TLS instead of + # OpenSSL/BoringSSL. Schannel verifies server certificates against the + # Windows Certificate Store and enforces native OCSP/CRL revocation + # checking (SCH_CRED_REVOCATION_CHECK_CHAIN_EXCLUDE_ROOT) as well as + # Microsoft Certificate Trust List (CTL) / disallowed-CA updates. set(CURL_USE_SCHANNEL ON) elseif(APPLE) # Enable Apple Security.framework (SecTrust) native OS certificate # verification alongside OpenSSL/BoringSSL, so macOS builds verify peers - # against the system Keychain rather than requiring a CA bundle file path. + # against the macOS System Keychain (SecTrustEvaluateWithError) and enforce + # Apple's native OCSP/CRL policies and Valid/trustd revocation updates + # rather than relying on a static CA bundle file path. set(USE_APPLE_SECTRUST ON) # When curl detects static BoringSSL with a Clang toolchain, it appends # ${_curl_stdlib_cxx} to its link interface. Override curl's default of # "stdc++" (removed from modern Xcode SDKs) to Apple's "c++" (libc++). set(_curl_stdlib_cxx "c++") + else() + # On Linux, libcurl uses OpenSSL/BoringSSL and verifies server certificates + # against the OS trust store managed by the distribution's ca-certificates + # package (which removes or distrusts compromised root and intermediate CAs). + # Note: TransportCurl and WebSocketClientImpl resolve CURLOPT_CAINFO and + # CURLOPT_CAPATH at runtime across standard Linux distribution paths so + # prebuilt binaries do not depend solely on the build machine's detected + # CURL_CA_BUNDLE path. endif() # Disable optional external libraries that curl 8.23.0 enables by default: # - libssh2: SSH/SCP/SFTP protocol library (unused by Firebase REST). # - libpsl: Public Suffix List cookie-domain library (unused). - # - nghttp2: HTTP/2 framing library (Firebase REST pins to HTTP/1.1). + # - nghttp2: optional external HTTP/2 library. Not bundled, so the shipped + # desktop libraries speak HTTP/1.1 only; the REST transport opts into + # HTTP/2 (CURL_HTTP_VERSION_2TLS) automatically if a user-supplied libcurl + # is built with nghttp2. # - libidn2: Internationalized Domain Names library (unused). set(CURL_USE_LIBSSH2 OFF) set(CURL_USE_LIBPSL OFF) @@ -481,7 +498,8 @@ if(DESKTOP) set(CURL_ZSTD OFF CACHE STRING "Build curl with zstd support" FORCE) # curl 8.23.0 removed the legacy umbrella HTTP_ONLY option. Explicitly disable - # every non-HTTP(S) protocol so only HTTP and HTTPS are compiled into libcurl. + # every unused protocol so only HTTP, HTTPS, WS, and WSS (used by the REST + # layer and Realtime Database WebSockets) are compiled into libcurl. set(CURL_DISABLE_DICT ON) set(CURL_DISABLE_FILE ON) set(CURL_DISABLE_FTP ON) @@ -496,7 +514,7 @@ if(DESKTOP) set(CURL_DISABLE_SMTP ON) set(CURL_DISABLE_TELNET ON) set(CURL_DISABLE_TFTP ON) - set(CURL_DISABLE_WEBSOCKETS ON) + set(CURL_DISABLE_WEBSOCKETS OFF) # Skip building curl's test suite and example binaries. set(BUILD_TESTING OFF) @@ -505,77 +523,11 @@ if(DESKTOP) add_external_library(curl) # Clean up generic normal variables set specifically for curl so they do not - # leak into subsequent add_subdirectory() calls (libuv, zlib, firestore, etc.). + # leak into subsequent add_subdirectory() calls (zlib, firestore, etc.). unset(BUILD_STATIC_LIBS) unset(BUILD_EXAMPLES) - add_external_library(libuv) - add_external_library(zlib) - - add_external_library(uWebSockets) - - # Binutils on Mac doesn't support thread-local storage (required by - # websockets), but because we only use websockets via the scheduler, we don't - # need it. Deactivate this by blanking out the __thread keyword. - set(websockets_additional_defines "-D__thread=") - - # uWebSockets does not come with a CMakeLists file, so define the target. - # Note that since it depends on OpenSSL, only do so if that was found. - if (OPENSSL_FOUND) - if (MSVC OR APPLE) - set(uWebSockets_extra_src) - else() - # Epoll is only used on Linux, otherwise LibUV is used. - set(uWebSockets_extra_src - ${UWEBSOCKETS_SOURCE_DIR}/src/Epoll.cpp) - endif() - add_library(libuWS STATIC - ${uWebSockets_extra_src} - ${UWEBSOCKETS_SOURCE_DIR}/src/Extensions.cpp - ${UWEBSOCKETS_SOURCE_DIR}/src/Group.cpp - ${UWEBSOCKETS_SOURCE_DIR}/src/HTTPSocket.cpp - ${UWEBSOCKETS_SOURCE_DIR}/src/Hub.cpp - ${UWEBSOCKETS_SOURCE_DIR}/src/Networking.cpp - ${UWEBSOCKETS_SOURCE_DIR}/src/Node.cpp - ${UWEBSOCKETS_SOURCE_DIR}/src/Socket.cpp - ${UWEBSOCKETS_SOURCE_DIR}/src/WebSocket.cpp) - if(MSVC) - set(websockets_additional_defines - ${websockets_additional_defines} - -DWIN32_LEAN_AND_MEAN # Ensure that windows doesn't include winsock.h by - # default, as it can cause issues when libraries try - # to include winsock2.h later on in the process. - -DUSE_LIBUV=1 - ) - elseif(APPLE) - set(websockets_additional_defines - ${websockets_additional_defines} - -DUSE_LIBUV=1 - ) - endif() - - target_compile_definitions(libuWS - PRIVATE - ${websockets_additional_defines} - ) - target_include_directories(libuWS - PUBLIC - ${LIBUV_SOURCE_DIR}/include - ${UWEBSOCKETS_SOURCE_DIR} - ${ZLIB_SOURCE_DIR} - ${ZLIB_BINARY_DIR} - PRIVATE - ${OPENSSL_INCLUDE_DIR} - ) - target_link_libraries(libuWS - PRIVATE - OpenSSL::SSL - OpenSSL::Crypto - uv_a - zlibstatic - ) - endif() endif() if(DESKTOP AND NOT CMAKE_CROSSCOMPILING) diff --git a/README.md b/README.md index d8651d95ba..7ed28b2711 100644 --- a/README.md +++ b/README.md @@ -157,11 +157,9 @@ Currently, the third party libraries that can be provided this way are: | Library | | ------- | -| CURL | +| CURL (8.14.0 or newer, built with WebSocket support) | | FLATBUFFERS | -| LIBUV | | NANOPB | -| UWEBSOCKETS | | ZLIB | #### Building with CMake for iOS diff --git a/app/rest/CMakeLists.txt b/app/rest/CMakeLists.txt index cc22dd0171..d9a4e4ac52 100644 --- a/app/rest/CMakeLists.txt +++ b/app/rest/CMakeLists.txt @@ -26,6 +26,12 @@ enable_language(CXX) set(rest_SRCS controller_curl.cc controller_interface.cc + curl/curl_handles.cc + curl/curl_handles.h + curl/curl_io_loop.cc + curl/curl_io_loop.h + curl/tls_policy.cc + curl/tls_policy.h gzipheader.cc request.cc request_binary_gzip.cc @@ -99,8 +105,6 @@ add_definitions( -DHAVE_LIBZ # Ensure curl includes ZLIB headers. -DHAVE_ZLIB_H - # Enforce stricter curl typechecking. - -DCURL_STRICTER # Disable unused protocols/options to reduce vulnerability surface. -DCURL_DISABLE_FTP -DCURL_DISABLE_NTLM diff --git a/app/rest/controller_curl.cc b/app/rest/controller_curl.cc index bc7c14b4e1..8120fcd876 100644 --- a/app/rest/controller_curl.cc +++ b/app/rest/controller_curl.cc @@ -16,103 +16,77 @@ #include "app/rest/controller_curl.h" +#include #include +#include #include -#include "app/rest/transport_curl.h" -#include "app/src/include/firebase/internal/common.h" +#include "app/rest/curl/curl_io_loop.h" #include "app/src/include/firebase/internal/mutex.h" -#include "app/src/log.h" namespace firebase { namespace rest { -ControllerCurl::ControllerCurl(TransportCurl* transport, - TransferDirection direction, Response* response) - : transport_(transport), - direction_(direction), - is_paused_(false), - response_(response), - transferring_(false), - bytes_transferred_(0), - transfer_size_(0), - this_handle_(nullptr), - this_handle_mutex_(nullptr) {} - -ControllerCurl::~ControllerCurl() { - if (this_handle_mutex_) { - MutexLock lock(*this_handle_mutex_); - *this_handle_ = nullptr; - } -} +namespace { -#if defined(FIREBASE_USE_MOVE_OPERATORS) || defined(DOXYGEN) -ControllerCurl::ControllerCurl(ControllerCurl&& other) noexcept { - *this = std::move(other); +void PostToTransfer(const std::shared_ptr& control, + void (*action)(RestTransfer*)) { + curl::CurlIoLoop::PostIfRunning([control, action]() { + RestTransfer* transfer = nullptr; + { + MutexLock lock(control->mutex); + transfer = control->transfer; + } + if (transfer != nullptr) action(transfer); + }); } -ControllerCurl& ControllerCurl::operator=(ControllerCurl&& other) noexcept { - direction_ = other.direction_; - transport_ = other.transport_; - is_paused_ = other.is_paused_; - response_ = other.response_; - transferring_ = other.transferring_; - bytes_transferred_ = other.bytes_transferred_; - transfer_size_ = other.transfer_size_; - this_handle_ = other.this_handle_; - this_handle_mutex_ = other.this_handle_mutex_; - if (this_handle_mutex_) { - MutexLock lock(*this_handle_mutex_); - *this_handle_ = this; - } - other.this_handle_mutex_ = nullptr; - other.transport_ = nullptr; - return *this; -} -#endif // defined(FIREBASE_USE_MOVE_OPERATORS) || defined(DOXYGEN) +} // namespace + +ControllerCurl::ControllerCurl(std::shared_ptr control) + : control_(std::move(control)) {} + +ControllerCurl::~ControllerCurl() = default; bool ControllerCurl::Pause() { - if (this_handle_mutex_) { - MutexLock lock(*this_handle_mutex_); - if (transferring_ && !is_paused_) { - transport_->PauseRequest(response_); - is_paused_ = true; - return true; - } + if (!control_) return false; + { + MutexLock lock(control_->mutex); + if (!control_->transferring || control_->paused) return false; + control_->paused = true; } - return false; + PostToTransfer(control_, &TransferControl::PauseOnIoThread); + return true; } bool ControllerCurl::Resume() { - if (this_handle_mutex_) { - MutexLock lock(*this_handle_mutex_); - if (transferring_ && is_paused_) { - transport_->ResumeRequest(response_); - is_paused_ = false; - return true; - } + if (!control_) return false; + { + MutexLock lock(control_->mutex); + if (!control_->transferring || !control_->paused) return false; + control_->paused = false; } - return false; + PostToTransfer(control_, &TransferControl::ResumeOnIoThread); + return true; } bool ControllerCurl::IsPaused() { - if (this_handle_mutex_) { - MutexLock lock(*this_handle_mutex_); - return is_paused_; - } - return false; + if (!control_) return false; + MutexLock lock(control_->mutex); + return control_->paused; } bool ControllerCurl::Cancel() { - if (this_handle_mutex_) { - MutexLock lock(*this_handle_mutex_); - if (transferring_) { - transport_->CancelRequest(response_); - transferring_ = false; - return true; - } + if (!control_) return false; + { + MutexLock lock(control_->mutex); + if (!control_->transferring) return false; + control_->transferring = false; + control_->paused = false; + control_->canceled = true; } - return false; + PostToTransfer(control_, &TransferControl::CancelOnIoThread); + return true; } float ControllerCurl::Progress() { @@ -121,26 +95,13 @@ float ControllerCurl::Progress() { return transferred / size; } -int64_t ControllerCurl::TransferSize() { return transfer_size_; } - -int64_t ControllerCurl::BytesTransferred() { return bytes_transferred_; } - -void ControllerCurl::set_bytes_transferred(int64_t bytes_transferred) { - // Only set the bytes transferred if it is larger than the current value. - // Curl reports -1 for the bytes transferred if the transfer is just starting - // or is complete. - if (bytes_transferred > bytes_transferred_) { - bytes_transferred_ = bytes_transferred; - } +int64_t ControllerCurl::TransferSize() { + return control_ ? control_->transfer_size.load(std::memory_order_relaxed) : 0; } -void ControllerCurl::set_transfer_size(int64_t transfer_size) { - // Only set the transfer size if it is larger than the current value. - // Curl reports -1 for the transfer size if the transfer is just starting or - // is complete. - if (transfer_size > transfer_size_) { - transfer_size_ = transfer_size; - } +int64_t ControllerCurl::BytesTransferred() { + return control_ ? control_->bytes_transferred.load(std::memory_order_relaxed) + : 0; } } // namespace rest diff --git a/app/rest/controller_curl.h b/app/rest/controller_curl.h index c13d8288cb..0315addb04 100644 --- a/app/rest/controller_curl.h +++ b/app/rest/controller_curl.h @@ -17,17 +17,18 @@ #ifndef FIREBASE_APP_REST_CONTROLLER_CURL_H_ #define FIREBASE_APP_REST_CONTROLLER_CURL_H_ +#include #include +#include #include "app/rest/controller_interface.h" -#include "app/rest/response.h" #include "app/src/include/firebase/internal/common.h" #include "app/src/include/firebase/internal/mutex.h" namespace firebase { namespace rest { -class TransportCurl; +class RestTransfer; // Enum used by ControllerCurl to determine whether the controller is operating // on an upload or a download. @@ -36,99 +37,74 @@ enum TransferDirection { kTransferDirectionDownload, }; -// An implementation of a Controller that controls a running curl -// operation. This can be used to monitor progress, pause, restart or cancel a -// running operation. +// Shared state between a ControllerCurl (owned by the caller) and its in-flight +// RestTransfer (owned by the curl I/O thread). Both hold a shared_ptr so either +// may outlive the other without dangling pointers or global transfer tables. +struct TransferControl { + explicit TransferControl(TransferDirection dir) + : direction(dir), + mutex(Mutex::kModeNonRecursive), + transfer(nullptr), + transferring(true), + paused(false), + canceled(false), + bytes_transferred(0), + transfer_size(0) {} + + // Written exclusively on the curl I/O thread; read lock-free by callers. + // libcurl reports -1 while starting or complete, so keep the high-water mark. + void SetBytesTransferred(int64_t value) { + if (value > bytes_transferred.load(std::memory_order_relaxed)) { + bytes_transferred.store(value, std::memory_order_relaxed); + } + } + + void SetTransferSize(int64_t value) { + if (value > transfer_size.load(std::memory_order_relaxed)) { + transfer_size.store(value, std::memory_order_relaxed); + } + } + + // Defined in transport_curl.cc; invoked on the curl I/O thread. + static void PauseOnIoThread(RestTransfer* transfer); + static void ResumeOnIoThread(RestTransfer* transfer); + static void CancelOnIoThread(RestTransfer* transfer); + + const TransferDirection direction; + // Guards transfer, transferring, paused, and canceled. + Mutex mutex; + RestTransfer* transfer; + bool transferring; + bool paused; + bool canceled; + std::atomic bytes_transferred; + std::atomic transfer_size; +}; + +// Controls a running curl transfer (progress, pause, resume, cancel). class ControllerCurl : public Controller { public: - // Transport and Response are pointers to the transport layer and curl - // response, respectively. ControllerCurl does not take ownership of these, - // but needs access to them to perform controller operations. - // The owner of this class is responsible for ensuring that transport and - // response remain valid throughout the lifetime of the controller. (and - // conversely, that ControllerCurl's methods are not invoked after transport - // and response are no longer valid.) - ControllerCurl(TransportCurl* transport, TransferDirection direction, - Response* response); - + explicit ControllerCurl(std::shared_ptr control); ~ControllerCurl() override; #if defined(FIREBASE_USE_MOVE_OPERATORS) || defined(DOXYGEN) - ControllerCurl(ControllerCurl&& other) noexcept; - - ControllerCurl& operator=(ControllerCurl&& other) noexcept; + ControllerCurl(ControllerCurl&& other) noexcept = default; + ControllerCurl& operator=(ControllerCurl&& other) noexcept = default; #endif // defined(FIREBASE_USE_MOVE_OPERATORS) || defined(DOXYGEN) - // Pauses whatever the handle is doing. - bool Pause() override; + ControllerCurl(const ControllerCurl& other) = delete; + ControllerCurl& operator=(const ControllerCurl& other) = delete; - // Resumes a paused operation. + bool Pause() override; bool Resume() override; - - // Returns true if the operation is paused. bool IsPaused() override; - - // Cancels a request and disposes of any resources allocated as part of the - // request. bool Cancel() override; - - // Returns a float in the range [0.0 - 1.0], representing the progress of the - // operation so far, if known. (percent bytes transferred, usually.) float Progress() override; - - // Returns the number of bytes being transferred in this operation. (either - // upload or download.) int64_t TransferSize() override; - - // Returns the number of bytes that have been transferred so far. int64_t BytesTransferred() override; - // The following methods are called by TransportCurl to set the status of this - // object. - // Set whether a transfer is active. - void set_transferring(bool transferring) { transferring_ = transferring; } - - // Set the current number of bytes transferred. - void set_bytes_transferred(int64_t bytes_transferred); - - // Set the total size of the transfer. - void set_transfer_size(int64_t transfer_size); - - // Set the controller handle and mutex. - void InitializeControllerHandle(ControllerCurl** handle, Mutex* mutex) { - this_handle_ = handle; - this_handle_mutex_ = mutex; - } - - // Direction of the transfer. - TransferDirection direction() const { return direction_; } - private: - ControllerCurl(const ControllerCurl& other) = delete; - ControllerCurl& operator=(const ControllerCurl& other) = delete; - - TransportCurl* transport_; - - // Whether this is an upload or a download. - TransferDirection direction_; - - // Whether this operation is currently paused or not. - bool is_paused_; - - // The response that this controller is associated with. - Response* response_; - - // Whether the transfer is running. - bool transferring_; - // Number of bytes transferred. - int64_t bytes_transferred_; - // Total size of the transfer. - int64_t transfer_size_; - - // Handle to this controller which is updated when it's moved. - ControllerCurl** this_handle_; - // Guards this_handle. - Mutex* this_handle_mutex_; + std::shared_ptr control_; }; } // namespace rest diff --git a/app/rest/curl/curl_handles.cc b/app/rest/curl/curl_handles.cc new file mode 100644 index 0000000000..9aff97482d --- /dev/null +++ b/app/rest/curl/curl_handles.cc @@ -0,0 +1,57 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "app/rest/curl/curl_handles.h" + +#include +#include // NOLINT + +#include "app/src/assert.h" + +namespace firebase { +namespace rest { +namespace curl { + +namespace { + +std::mutex g_curl_global_mutex; +int64_t g_curl_global_refcount = 0; + +} // namespace + +void AcquireCurlGlobal() { + std::lock_guard lock(g_curl_global_mutex); + if (g_curl_global_refcount++ == 0) { + CURLcode code = curl_global_init(CURL_GLOBAL_ALL); + FIREBASE_ASSERT_MESSAGE(code == CURLE_OK, + "curl_global_init(CURL_GLOBAL_ALL) failed: %s", + curl_easy_strerror(code)); + } +} + +void ReleaseCurlGlobal() { + std::lock_guard lock(g_curl_global_mutex); + FIREBASE_ASSERT_MESSAGE( + g_curl_global_refcount > 0, + "ReleaseCurlGlobal called more times than AcquireCurlGlobal"); + if (--g_curl_global_refcount == 0) { + curl_global_cleanup(); + } +} + +} // namespace curl +} // namespace rest +} // namespace firebase diff --git a/app/rest/curl/curl_handles.h b/app/rest/curl/curl_handles.h new file mode 100644 index 0000000000..3759b49ab5 --- /dev/null +++ b/app/rest/curl/curl_handles.h @@ -0,0 +1,74 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#ifndef FIREBASE_APP_REST_CURL_CURL_HANDLES_H_ +#define FIREBASE_APP_REST_CURL_CURL_HANDLES_H_ + +#include + +#include "curl/curl.h" + +// The desktop SDK drives libcurl through APIs that only exist in recent +// releases: curl_multi_poll()/curl_multi_wakeup() (7.68), CURLOPT_PROTOCOLS_STR +// (7.85), CURLOPT_CONNECT_ONLY=2 WebSocket handshakes (7.86) and +// CURLWS_NOAUTOPONG (8.14). Fail at compile time with a clear message rather +// than with scattered "undeclared identifier" errors when a user points +// CURL_SOURCE_DIR at an older tree. +static_assert(LIBCURL_VERSION_NUM >= 0x080e00, + "Firebase C++ SDK requires libcurl >= 8.14.0 (WebSockets with " + "CURLWS_NOAUTOPONG)"); + +namespace firebase { +namespace rest { +namespace curl { + +// RAII ownership for libcurl easy, multi, and slist handles. All deleters are +// null-safe. These are plain std::unique_ptr aliases so ownership transfer, +// release() and reset() behave exactly as for any other unique_ptr. +struct EasyDeleter { + void operator()(CURL* easy) const { + if (easy != nullptr) curl_easy_cleanup(easy); + } +}; + +struct MultiDeleter { + void operator()(CURLM* multi) const { + if (multi != nullptr) curl_multi_cleanup(multi); + } +}; + +struct SListDeleter { + void operator()(curl_slist* list) const { + if (list != nullptr) curl_slist_free_all(list); + } +}; + +using UniqueEasy = std::unique_ptr; +using UniqueMulti = std::unique_ptr; +using UniqueSList = std::unique_ptr; + +// Reference-counted curl_global_init() / curl_global_cleanup(). Thread-safe. +// Every libcurl user in the SDK holds a reference while it owns any CURL +// handle, so libcurl's process-wide state is initialized exactly once and torn +// down only after the last user is gone. +void AcquireCurlGlobal(); +void ReleaseCurlGlobal(); + +} // namespace curl +} // namespace rest +} // namespace firebase + +#endif // FIREBASE_APP_REST_CURL_CURL_HANDLES_H_ diff --git a/app/rest/curl/curl_io_loop.cc b/app/rest/curl/curl_io_loop.cc new file mode 100644 index 0000000000..983ce3ff96 --- /dev/null +++ b/app/rest/curl/curl_io_loop.cc @@ -0,0 +1,343 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "app/rest/curl/curl_io_loop.h" + +#include +#include + +#include "app/src/assert.h" +#include "app/src/log.h" + +namespace firebase { +namespace rest { +namespace curl { + +namespace { + +// Upper bound on how long curl_multi_poll() sleeps when nothing happens. This +// is purely a safety net: libcurl shortens the wait to its own next timer +// (connect timeouts, CURLOPT_TIMEOUT_MS, retries, ...) and Post() wakes the +// thread immediately via curl_multi_wakeup(), so in practice the loop only +// ever wakes when there is something to do. +constexpr int kPollTimeoutMs = 1000; + +// Singleton state. The mutexes are intentionally leaked so they stay valid +// during static destruction, matching the other process-wide mutexes in +// app/rest. +Mutex* g_loop_mutex = new Mutex(); +int g_loop_ref_count = 0; +// Read lock-free by Get() so the I/O thread itself can call Get() while +// Release() is joining it under g_loop_mutex. +std::atomic g_loop{nullptr}; +// Non-null only on the live CurlIoLoop worker thread inside Run(), allowing +// PostIfRunning() and IsCurrentThreadIoThread() to avoid locking g_loop_mutex +// (which Release() holds while joining the worker thread). +thread_local CurlIoLoop* g_tls_io_loop = nullptr; + +} // namespace + +Transfer::~Transfer() {} + +// --------------------------------------------------------------------------- +// Singleton management. +// --------------------------------------------------------------------------- + +void CurlIoLoop::Acquire() { + MutexLock lock(*g_loop_mutex); + if (g_loop_ref_count == 0) { + FIREBASE_ASSERT(g_loop.load() == nullptr); + AcquireCurlGlobal(); + g_loop.store(new CurlIoLoop()); + } + ++g_loop_ref_count; +} + +void CurlIoLoop::Release() { + MutexLock lock(*g_loop_mutex); + FIREBASE_ASSERT_MESSAGE(g_loop_ref_count > 0, + "CurlIoLoop::Release() without matching Acquire()"); + if (--g_loop_ref_count == 0) { + CurlIoLoop* loop = g_loop.load(); + FIREBASE_ASSERT_MESSAGE(!loop->IsOnIoThread(), + "CurlIoLoop::Release() of the last reference must " + "not be called from the I/O thread"); + // Destroy before clearing the pointer so that Get() keeps working for + // code running on the I/O thread during shutdown (OnDone handlers). + delete loop; + g_loop.store(nullptr); + ReleaseCurlGlobal(); + } +} + +CurlIoLoop& CurlIoLoop::Get() { + CurlIoLoop* loop = g_loop.load(); + FIREBASE_ASSERT_MESSAGE(loop != nullptr, + "CurlIoLoop::Get() called without a prior " + "CurlIoLoop::Acquire() / InitTransportCurl()"); + return *loop; +} + +bool CurlIoLoop::IsRunning() { return g_loop.load() != nullptr; } + +bool CurlIoLoop::IsCurrentThreadIoThread() { return g_tls_io_loop != nullptr; } + +bool CurlIoLoop::PostIfRunning(std::function fn) { + if (g_tls_io_loop != nullptr) { + return g_tls_io_loop->Post(std::move(fn)); + } + MutexLock lock(*g_loop_mutex); + CurlIoLoop* loop = g_loop.load(); + return loop != nullptr && loop->Post(std::move(fn)); +} + +// --------------------------------------------------------------------------- +// Construction / destruction. +// --------------------------------------------------------------------------- + +CurlIoLoop::CurlIoLoop() + : multi_(nullptr), + thread_(), + thread_id_(), + thread_started_(0), + mutex_(Mutex::kModeNonRecursive), + posted_(), + stopped_(false), + quit_(false), + transfers_() { + multi_.reset(curl_multi_init()); + FIREBASE_ASSERT_MESSAGE(multi_ != nullptr, "curl_multi_init() failed"); + // Enable HTTP/2 connection multiplexing on the shared multi handle (a no-op + // when libcurl is built without HTTP/2 support). + curl_multi_setopt(multi_.get(), CURLMOPT_PIPELINING, CURLPIPE_MULTIPLEX); + thread_ = Thread(ThreadMain, this); + // Wait until the thread has published its id so IsOnIoThread() is reliable + // for every caller from here on. + thread_started_.Wait(); +} + +CurlIoLoop::~CurlIoLoop() { + Post([this]() { quit_ = true; }); + thread_.Join(); + // Synchronize with any Post() that observed stopped_ == false (it holds + // mutex_ while it wakes us) before multi_ is destroyed below. + MutexLock lock(mutex_); + FIREBASE_ASSERT(stopped_); + FIREBASE_ASSERT(transfers_.empty()); +} + +// --------------------------------------------------------------------------- +// Cross-thread entry points. +// --------------------------------------------------------------------------- + +bool CurlIoLoop::Post(std::function fn) { + FIREBASE_ASSERT(fn); + MutexLock lock(mutex_); + if (stopped_) { + LogError("CurlIoLoop::Post() called after the I/O loop stopped; dropping"); + return false; + } + posted_.push_back(std::move(fn)); + // Called under mutex_ so the loop cannot stop (and multi_ cannot be + // destroyed) between the push and the wakeup. curl_multi_wakeup() is + // thread-safe and merely writes to an internal socket pair. + CURLMcode code = curl_multi_wakeup(multi_.get()); + if (code != CURLM_OK) { + LogError("curl_multi_wakeup() failed: %s", curl_multi_strerror(code)); + } + return true; +} + +bool CurlIoLoop::IsOnIoThread() const { + return Thread::IsCurrentThread(thread_id_); +} + +void CurlIoLoop::AssertOnIoThread(const char* what) const { + FIREBASE_ASSERT_MESSAGE(IsOnIoThread(), + "CurlIoLoop::%s() must be called on the I/O thread " + "(use CurlIoLoop::Post())", + what); +} + +// --------------------------------------------------------------------------- +// I/O-thread-only API. +// --------------------------------------------------------------------------- + +CURLMcode CurlIoLoop::Add(Transfer& transfer) { + AssertOnIoThread("Add"); + CURL* easy = transfer.easy(); + FIREBASE_ASSERT_MESSAGE(easy != nullptr, + "Transfer::easy() returned nullptr in Add()"); + if (transfers_.find(easy) != transfers_.end()) { + LogError("CurlIoLoop::Add(): easy handle %p is already registered", easy); + return CURLM_ADDED_ALREADY; + } + CURLMcode code = curl_multi_add_handle(multi_.get(), easy); + if (code != CURLM_OK) { + LogError("curl_multi_add_handle() failed: %s", curl_multi_strerror(code)); + return code; + } + transfers_.emplace(easy, &transfer); + return CURLM_OK; +} + +bool CurlIoLoop::Remove(Transfer& transfer) { + AssertOnIoThread("Remove"); + CURL* easy = transfer.easy(); + auto it = transfers_.find(easy); + if (it == transfers_.end() || it->second != &transfer) return false; + transfers_.erase(it); + CURLMcode code = curl_multi_remove_handle(multi_.get(), easy); + if (code != CURLM_OK) { + LogError("curl_multi_remove_handle() failed: %s", + curl_multi_strerror(code)); + } + return true; +} + +CURLM* CurlIoLoop::multi() { + AssertOnIoThread("multi"); + return multi_.get(); +} + +size_t CurlIoLoop::transfer_count() const { + AssertOnIoThread("transfer_count"); + return transfers_.size(); +} + +// --------------------------------------------------------------------------- +// The loop. +// --------------------------------------------------------------------------- + +void CurlIoLoop::ThreadMain(void* loop) { + static_cast(loop)->Run(); +} + +size_t CurlIoLoop::RunPosted() { + size_t count = 0; + for (;;) { + std::deque> batch; + { + MutexLock lock(mutex_); + if (posted_.empty()) return count; + batch.swap(posted_); + } + for (std::function& fn : batch) { + fn(); + ++count; + } + // Functions posted by the batch we just ran are picked up by the next + // pass so that "post then wait for I/O" sequences never miss a wakeup. + } +} + +void CurlIoLoop::DispatchDone() { + int messages_left = 0; + CURLMsg* message = nullptr; + while ((message = curl_multi_info_read(multi_.get(), &messages_left)) != + nullptr) { + if (message->msg != CURLMSG_DONE) continue; + // Copy out everything we need: the message is invalidated by + // curl_multi_remove_handle(). + CURL* easy = message->easy_handle; + CURLcode result = message->data.result; + auto it = transfers_.find(easy); + if (it == transfers_.end()) { + curl_multi_remove_handle(multi_.get(), easy); + LogWarning("CurlIoLoop: CURLMSG_DONE for unknown easy handle %p", easy); + continue; + } + Transfer* transfer = it->second; + transfers_.erase(it); + curl_multi_remove_handle(multi_.get(), easy); + transfer->OnDone(result); + } +} + +void CurlIoLoop::AbortAllTransfers() { + while (!transfers_.empty()) { + auto it = transfers_.begin(); + CURL* easy = it->first; + Transfer* transfer = it->second; + transfers_.erase(it); + curl_multi_remove_handle(multi_.get(), easy); + transfer->OnDone(CURLE_ABORTED_BY_CALLBACK); + } +} + +void CurlIoLoop::Run() { + g_tls_io_loop = this; + struct ScopedTlsReset { + ~ScopedTlsReset() { g_tls_io_loop = nullptr; } + } reset_tls; + + thread_id_ = Thread::CurrentId(); + thread_started_.Post(); + + while (!quit_) { + RunPosted(); + if (quit_) break; + + int running_handles = 0; + CURLMcode code = curl_multi_perform(multi_.get(), &running_handles); + if (code != CURLM_OK) { + LogError("curl_multi_perform() failed: %s", curl_multi_strerror(code)); + } + DispatchDone(); + + // Sleep until a libcurl socket or timer fires or Post() wakes us. libcurl + // clamps the timeout to its own next timer. + int num_fds = 0; + code = curl_multi_poll(multi_.get(), nullptr, 0, kPollTimeoutMs, &num_fds); + if (code != CURLM_OK) { + LogError("curl_multi_poll() failed: %s", curl_multi_strerror(code)); + } + } + + // Shutdown: run anything posted after the quit request, give libcurl one + // last non-blocking pass so transfers whose response is already fully + // received (or whose CURLMSG_DONE is already queued) are reported with their + // real result rather than as aborted, then abort every transfer still in + // flight so owners waiting on them are released, then run anything those + // OnDone() handlers posted. Further Post() calls are rejected once stopped_ + // is set. + RunPosted(); + if (!transfers_.empty()) { + int running_handles = 0; + curl_multi_perform(multi_.get(), &running_handles); + DispatchDone(); + } + AbortAllTransfers(); + std::deque> remaining; + { + MutexLock lock(mutex_); + stopped_ = true; + remaining.swap(posted_); + } + for (std::function& fn : remaining) fn(); + // A transfer added by one of the functions above can never complete. + if (!transfers_.empty()) { + LogError( + "CurlIoLoop stopped with %d transfer(s) added during shutdown; " + "aborting them", + static_cast(transfers_.size())); + AbortAllTransfers(); + } +} + +} // namespace curl +} // namespace rest +} // namespace firebase diff --git a/app/rest/curl/curl_io_loop.h b/app/rest/curl/curl_io_loop.h new file mode 100644 index 0000000000..26ebe0f564 --- /dev/null +++ b/app/rest/curl/curl_io_loop.h @@ -0,0 +1,210 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#ifndef FIREBASE_APP_REST_CURL_CURL_IO_LOOP_H_ +#define FIREBASE_APP_REST_CURL_CURL_IO_LOOP_H_ + +#include +#include +#include +#include +#include + +#include "app/rest/curl/curl_handles.h" +#include "app/src/include/firebase/internal/mutex.h" +#include "app/src/semaphore.h" +#include "app/src/thread.h" +#include "curl/curl.h" + +namespace firebase { +namespace rest { +namespace curl { + +// One libcurl easy handle driven by the shared CurlIoLoop. +// +// A Transfer is the unit of work the I/O loop understands: it owns one +// configured CURL easy handle and is notified on the I/O thread when libcurl +// finishes driving it. +// +// Lifetime and threading contract: +// * All methods are invoked on the I/O thread only. +// * The Transfer must stay alive from CurlIoLoop::Add() until either +// CurlIoLoop::Remove() returns or OnDone() has been called, whichever +// happens first. After OnDone() the loop holds no reference to it, so +// OnDone() may `delete this`. +// * easy() must return the same handle for the whole time the transfer is +// registered with the loop. +class Transfer { + public: + virtual ~Transfer(); + + // The easy handle this transfer drives. Must already be configured when the + // transfer is passed to CurlIoLoop::Add(). + virtual CURL* easy() = 0; + + // Called exactly once on the I/O thread when libcurl reports the transfer + // finished (CURLMSG_DONE) or when the loop is shutting down with the + // transfer still in flight (result == CURLE_ABORTED_BY_CALLBACK). The loop + // has forgotten the Transfer and removed the easy handle from the multi + // handle by the time this runs, so it is safe to destroy both from here. + // + // IMPORTANT: this runs on the single process-wide I/O thread that services + // every REST transfer in the process. Implementations MUST keep it short: + // record the result, hand off to the owner's own executor / scheduler / + // semaphore, and return. Never block, never perform network I/O, and never + // run product or application code here. + virtual void OnDone(CURLcode result) = 0; +}; + +// The single process-wide libcurl I/O thread for REST transfers. +// +// CurlIoLoop owns one CURLM multi handle and one thread that drives it with +// the idiomatic libcurl event loop: +// +// while (!quit) { +// run functions queued with Post(); +// curl_multi_perform(); +// for each CURLMSG_DONE: curl_multi_remove_handle(); Transfer::OnDone(); +// curl_multi_poll(multi, nullptr, 0, timeout, &numfds); // sleeps +// } +// +// curl_multi_poll() blocks until a libcurl socket is ready, a libcurl timer +// expires, or another thread calls Post() (which uses curl_multi_wakeup()). +// There is no timer-based polling: an idle loop costs zero CPU and reacts to +// new work in microseconds. The one shared multi handle also means connection +// reuse, HTTP/2 multiplexing, DNS cache and TLS session cache are shared by +// every REST product in the process. +// +// --------------------------------------------------------------------------- +// Thread-safety contract +// --------------------------------------------------------------------------- +// * Every CURL easy handle driven by the loop is created, configured, driven +// and destroyed on the I/O thread. +// * Every other thread interacts with the loop exclusively through Post(), +// which is thread-safe and runs the function on the I/O thread. +// * Add(), Remove() and multi() assert that they are called on the I/O +// thread, i.e. from inside a posted function or Transfer::OnDone(). +// * Functions run by the loop (posted functions, Transfer::OnDone()) must be +// short and must never block: they share the one thread with every transfer +// in the process. Hand real work to your own executor. +// +// --------------------------------------------------------------------------- +// Lifetime +// --------------------------------------------------------------------------- +// The loop is a reference-counted singleton. Acquire() initializes libcurl's +// global state and starts the thread on the first reference; Release() stops +// the thread and releases libcurl's global state when the last reference goes +// away. Products call these through rest::InitTransportCurl() / +// CleanupTransportCurl(). Shutdown posts a quit request and joins the thread; +// any Transfer still registered at that point is removed from the multi handle +// and receives OnDone(CURLE_ABORTED_BY_CALLBACK) on the I/O thread before +// Release() returns, so owners blocked waiting for completion are always +// released. +// +// Release() must not be called from the I/O thread itself (it would join +// itself). +class CurlIoLoop { + public: + // Reference counting. Thread-safe. Acquire() returns once the I/O thread is + // running; Release() returns once it has stopped (on the last reference). + static void Acquire(); + static void Release(); + // The live loop. Only valid between Acquire() and the matching Release(); + // asserts otherwise. Lock-free, so it may be called from the I/O thread. + static CurlIoLoop& Get(); + // True if a loop is currently alive (for diagnostics/tests). + static bool IsRunning(); + // True when the calling thread is a live CurlIoLoop worker thread. Safe to + // call from any thread regardless of whether a loop is running. + static bool IsCurrentThreadIoThread(); + // Atomically posts `fn` to the active loop if one is running, serialized + // against Acquire()/Release() when called off the I/O thread. Returns false + // without asserting if no loop is running or the loop has already stopped. + static bool PostIfRunning(std::function fn); + + // Queues fn to run on the I/O thread as soon as possible and wakes the + // thread. Thread-safe. Functions run in FIFO order relative to each other. + // + // When called *from* the I/O thread the function is still enqueued (never + // run inline): it will run on the next pass of the loop, after the current + // callback returns. This keeps FIFO ordering simple and makes it safe to + // Post() from inside OnDone() and other posted functions without + // re-entrancy concerns. + // + // Posting after the loop has stopped returns false and drops the function + // (with an error log); this can only happen if a caller outlives its own + // Release(), which is a bug in the caller. Returns true if fn was queued. + bool Post(std::function fn); + + // True when called on the I/O thread. + bool IsOnIoThread() const; + + // Registers a transfer: curl_multi_add_handle(transfer.easy()). I/O thread + // only. On success the loop remembers the transfer and will call its + // OnDone() exactly once. On failure (returns != CURLM_OK) the transfer is + // not registered and OnDone() will never be called for it. + CURLMcode Add(Transfer& transfer); + + // Unregisters a transfer before it finished: curl_multi_remove_handle() and + // forget it. I/O thread only. OnDone() will NOT be called for it. Safe to + // call for a transfer that is not (or no longer) registered; returns false + // in that case. + bool Remove(Transfer& transfer); + + // The multi handle, for consumers that need curl_multi_setopt() or + // CURLMOPT_* tuning. I/O thread only. + CURLM* multi(); + + // Number of registered transfers (for tests). I/O thread only. + size_t transfer_count() const; + + CurlIoLoop(const CurlIoLoop&) = delete; + CurlIoLoop& operator=(const CurlIoLoop&) = delete; + + private: + CurlIoLoop(); + ~CurlIoLoop(); + + static void ThreadMain(void* loop); + void Run(); + // Runs every queued function. Returns the number run. + size_t RunPosted(); + // Handles every pending CURLMSG_DONE. + void DispatchDone(); + // Removes and aborts every registered transfer (shutdown path). + void AbortAllTransfers(); + void AssertOnIoThread(const char* what) const; + + UniqueMulti multi_; + Thread thread_; + Thread::Id thread_id_; + Semaphore thread_started_; + + // Guards posted_ and stopped_. + Mutex mutex_; + std::deque> posted_; + bool stopped_; + + // I/O thread only from here on. + bool quit_; + std::unordered_map transfers_; +}; + +} // namespace curl +} // namespace rest +} // namespace firebase + +#endif // FIREBASE_APP_REST_CURL_CURL_IO_LOOP_H_ diff --git a/app/rest/curl/tls_policy.cc b/app/rest/curl/tls_policy.cc new file mode 100644 index 0000000000..138ca89ab2 --- /dev/null +++ b/app/rest/curl/tls_policy.cc @@ -0,0 +1,121 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "app/rest/curl/tls_policy.h" + +#ifdef FIREBASE_SSL_CAPATH +#include +#include +#endif // FIREBASE_SSL_CAPATH + +namespace firebase { +namespace rest { +namespace curl { + +namespace { + +// Applies a single option and folds the result into *first_error, keeping only +// the first failure so the caller sees the root cause. +template +void ApplyOpt(CURL* easy, CURLoption option, T value, CURLcode* first_error) { + CURLcode code = curl_easy_setopt(easy, option, value); + if (code != CURLE_OK && *first_error == CURLE_OK) *first_error = code; +} + +} // namespace + +CURLcode ApplyTlsPolicy(CURL* easy) { + CURLcode first_error = CURLE_OK; + ApplyOpt(easy, CURLOPT_SSL_VERIFYPEER, 1L, &first_error); + ApplyOpt(easy, CURLOPT_SSL_VERIFYHOST, 2L, &first_error); + ApplyOpt(easy, CURLOPT_SSLVERSION, + static_cast(CURL_SSLVERSION_TLSv1_2), // NOLINT + &first_error); +#ifdef FIREBASE_SSL_CAPATH + // Explicitly setting CURLOPT_CAINFO (even to nullptr) marks + // ssl.custom_cafile = TRUE inside libcurl, which stops it from substituting + // the build machine's compile-time CURL_CA_BUNDLE path. See the comment on + // LinuxCaBundlePath() for why that matters. + ApplyOpt(easy, CURLOPT_CAINFO, LinuxCaBundlePath(), &first_error); + ApplyOpt(easy, CURLOPT_CAPATH, LinuxCaDirectoryPath(), &first_error); +#endif // FIREBASE_SSL_CAPATH + return first_error; +} + +// Standard system CA bundle locations across major Linux distributions: +// - /etc/ssl/certs/ca-certificates.crt: Debian, Ubuntu, Gentoo, Arch, Alpine +// - /etc/pki/tls/certs/ca-bundle.crt: Fedora, RHEL, CentOS, Rocky, Alma, +// Amazon Linux +// - /etc/ssl/ca-bundle.pem: openSUSE, SLES +// - /etc/pki/tls/cacert.pem: OpenELEC +// - /etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem: CentOS/RHEL p11-kit +// trust store +// - /etc/ssl/cert.pem: Alpine, Arch, Fedora/FreeBSD-style symlink +// +// Why we probe at runtime instead of relying on libcurl's compile-time +// CURL_CA_BUNDLE: +// 1. At CMake configure time, libcurl detects the CA bundle path on the +// *build* host (typically Ubuntu's /etc/ssl/certs/ca-certificates.crt) and +// compiles that single path into CURL_CA_BUNDLE. +// 2. When linked against BoringSSL (or OpenSSL < 3.0), libcurl calls +// X509_STORE_load_locations(store, ssl_cafile, ssl_capath). BoringSSL's +// X509_STORE_load_locations checks ssl_cafile first and immediately returns +// 0 (CURLE_SSL_CACERT_BADFILE) if that file does not exist on the runtime +// machine, aborting before it ever loads ssl_capath. +// 3. Conversely, some Linux distributions and container images only ship a +// combined CA bundle file without populating OpenSSL c_rehash (.0) +// symlinks in /etc/ssl/certs, so setting only CURLOPT_CAPATH is +// insufficient. +// Explicitly setting CURLOPT_CAINFO to LinuxCaBundlePath() (even when it +// returns nullptr) marks ssl.custom_cafile = TRUE in libcurl, preventing a +// missing build-host CURL_CA_BUNDLE path from breaking verification while +// loading both the runtime distribution's CA bundle and hashed CA directory +// when available. +const char* LinuxCaBundlePath() { +#ifdef FIREBASE_SSL_CAPATH + static const char* const kCaBundlePaths[] = { + "/etc/ssl/certs/ca-certificates.crt", + "/etc/pki/tls/certs/ca-bundle.crt", + "/etc/ssl/ca-bundle.pem", + "/etc/pki/tls/cacert.pem", + "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem", + "/etc/ssl/cert.pem", + }; + for (const char* path : kCaBundlePaths) { + struct stat st; + if (stat(path, &st) == 0 && S_ISREG(st.st_mode) && + access(path, R_OK) == 0) { + return path; + } + } +#endif // FIREBASE_SSL_CAPATH + return nullptr; +} + +const char* LinuxCaDirectoryPath() { +#ifdef FIREBASE_SSL_CAPATH + struct stat st; + if (stat(FIREBASE_SSL_CAPATH, &st) == 0 && S_ISDIR(st.st_mode) && + access(FIREBASE_SSL_CAPATH, R_OK | X_OK) == 0) { + return FIREBASE_SSL_CAPATH; + } +#endif // FIREBASE_SSL_CAPATH + return nullptr; +} + +} // namespace curl +} // namespace rest +} // namespace firebase diff --git a/app/rest/curl/tls_policy.h b/app/rest/curl/tls_policy.h new file mode 100644 index 0000000000..ac4f55b6bf --- /dev/null +++ b/app/rest/curl/tls_policy.h @@ -0,0 +1,64 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#ifndef FIREBASE_APP_REST_CURL_TLS_POLICY_H_ +#define FIREBASE_APP_REST_CURL_TLS_POLICY_H_ + +#include "curl/curl.h" + +namespace firebase { +namespace rest { +namespace curl { + +// Applies the SDK-wide TLS policy to a libcurl easy handle. This is the single +// place where transport security is configured for every HTTPS and WSS +// connection the SDK opens (REST transport and Realtime Database WebSockets). +// +// The policy is: +// * Always verify the peer certificate chain (CURLOPT_SSL_VERIFYPEER=1) and +// that the certificate matches the requested hostname/SANs +// (CURLOPT_SSL_VERIFYHOST=2). +// * Require TLS 1.2 or newer (CURLOPT_SSLVERSION=TLSv1_2). TLS 1.3 is still +// negotiated when available; TLS 1.0/1.1 downgrades are refused. +// * Trust the operating system's certificate store so that root and +// intermediate CAs revoked or distrusted by the OS vendor are rejected: +// - Windows (CURL_USE_SCHANNEL): Windows Certificate Store via Crypt32, +// with native OCSP/CRL and Microsoft CTL revocation checks. +// - macOS (USE_APPLE_SECTRUST): System Keychain via Security.framework +// (SecTrustEvaluateWithError), with Apple OCSP/CRL and Valid/trustd +// revocation checks. +// - Linux (OpenSSL/BoringSSL): the distribution's ca-certificates trust +// store, resolved at *runtime* by LinuxCaBundlePath() and +// LinuxCaDirectoryPath() rather than relying on the path libcurl +// detected on the build machine. +// +// Returns the first non-CURLE_OK result from curl_easy_setopt(), or CURLE_OK. +CURLcode ApplyTlsPolicy(CURL* easy); + +// Returns the first readable system CA bundle file on the current Linux host +// (probing standard Debian/Ubuntu, RHEL/Fedora, openSUSE, and Alpine paths), +// or nullptr if no bundle file is found or on non-Linux platforms. +const char* LinuxCaBundlePath(); + +// Returns FIREBASE_SSL_CAPATH ("/etc/ssl/certs") if it exists as a readable +// directory on the current Linux host, or nullptr otherwise. +const char* LinuxCaDirectoryPath(); + +} // namespace curl +} // namespace rest +} // namespace firebase + +#endif // FIREBASE_APP_REST_CURL_TLS_POLICY_H_ diff --git a/app/rest/response.cc b/app/rest/response.cc index e57bee707b..677d677780 100644 --- a/app/rest/response.cc +++ b/app/rest/response.cc @@ -16,6 +16,7 @@ #include "app/rest/response.h" +#include #include #include "app/rest/util.h" @@ -24,6 +25,70 @@ namespace firebase { namespace rest { +namespace { + +// Parses an HTTP/1.x, HTTP/2, or HTTP/3 status line such as: +// "HTTP/1.1 200 OK\r\n" +// "HTTP/1.1 204\r\n" +// "HTTP/2 200 \r\n" +// "HTTP/2 200\r\n" +// Returns true and writes *status_out when valid; leaves *status_out unchanged +// otherwise. +bool ParseHttpStatusLine(const std::string& line, int* status_out) { + if (line.size() < 9 || util::ToUpper(line.substr(0, 5)) != "HTTP/") { + return false; + } + size_t pos = 5; + // Major version: one or more digits. + size_t major_start = pos; + while (pos < line.size() && + std::isdigit(static_cast(line[pos]))) { + ++pos; + } + if (pos == major_start) return false; + + // Optional ".minor" version (present in HTTP/1.0, HTTP/1.1; omitted in + // HTTP/2 and HTTP/3 status lines synthesized by libcurl). + if (pos < line.size() && line[pos] == '.') { + ++pos; + size_t minor_start = pos; + while (pos < line.size() && + std::isdigit(static_cast(line[pos]))) { + ++pos; + } + if (pos == minor_start) return false; + } + + // Must be followed by whitespace before the 3-digit status code. + if (pos >= line.size() || (line[pos] != ' ' && line[pos] != '\t')) { + return false; + } + while (pos < line.size() && (line[pos] == ' ' || line[pos] == '\t')) { + ++pos; + } + + // Status code: 3 digits in [100, 599], followed by whitespace, CR/LF, or EOS. + if (pos + 3 > line.size()) return false; + int code = 0; + for (int i = 0; i < 3; ++i) { + unsigned char ch = static_cast(line[pos + i]); + if (!std::isdigit(ch)) return false; + code = code * 10 + (ch - '0'); + } + pos += 3; + if (pos < line.size()) { + char next = line[pos]; + if (next != ' ' && next != '\t' && next != '\r' && next != '\n') { + return false; + } + } + if (code < 100 || code > 599) return false; + *status_out = code; + return true; +} + +} // namespace + Response::Response() : status_(0), header_completed_(false), @@ -40,21 +105,24 @@ bool Response::ProcessHeader(const char* buffer, size_t length) { if (header.compare(util::kCrLf) == 0) { // A single CRLF line marks the end of header. header_completed_ = true; - } else { - // Scan status code and ignore version as well as reason-phrase strings. - sscanf(header.c_str(), "HTTP/%*d.%*d %d %*s\r\n", &status_); + } else if (ParseHttpStatusLine(header, &status_)) { + // A new status line (e.g. final 200 after 100 Continue or 302 redirect) + // starts a fresh header block. + header_completed_ = false; } } else { - // A header line with key and value, separated by colon. - const std::string& key = - util::TrimWhitespace(header.substr(0, colon_index)); - const std::string& value = + // HTTP header field names are case-insensitive (RFC 9110 section 5.1) and + // always lowercase in HTTP/2 (RFC 9113 section 8.2). Normalize keys to + // lowercase and let later headers overwrite interim/redirect ones. + const std::string key = + util::ToLower(util::TrimWhitespace(header.substr(0, colon_index))); + const std::string value = util::TrimWhitespace(header.substr(colon_index + 1)); - header_.emplace(key, value); + header_[key] = value; // Below we update this response object by each header. // Update fetch_time_ from Date. - if (key == util::kDate) { + if (key == "date") { fetch_time_ = curl_getdate(value.c_str(), nullptr /* unused */); } } @@ -69,7 +137,10 @@ bool Response::ProcessBody(const char* buffer, size_t length) { } const char* Response::GetHeader(const char* name) { - auto iter = header_.find(name); + if (name == nullptr) { + return nullptr; + } + auto iter = header_.find(util::ToLower(name)); if (iter == header_.end()) { return nullptr; } else { diff --git a/app/rest/tests/CMakeLists.txt b/app/rest/tests/CMakeLists.txt index f122099235..4b6720fe5d 100644 --- a/app/rest/tests/CMakeLists.txt +++ b/app/rest/tests/CMakeLists.txt @@ -120,6 +120,60 @@ firebase_cpp_cc_test(firebase_app_rest_transport_mock_test firebase_testing ) +# Tests below talk to a localhost HTTP server (test_http_server.h) and need +# libcurl headers plus the platform socket library. +firebase_cpp_cc_test(firebase_app_rest_curl_io_loop_test + SOURCES + curl_io_loop_test.cc + test_http_server.h + INCLUDES + ${CURL_SOURCE_DIR}/include + DEPENDS + firebase_app + firebase_rest_lib + libcurl +) + +firebase_cpp_cc_test(firebase_app_rest_transport_curl_test + SOURCES + transport_curl_test.cc + test_http_server.h + INCLUDES + ${CURL_SOURCE_DIR}/include + DEPENDS + firebase_app + firebase_rest_lib + libcurl +) + +firebase_cpp_cc_test(firebase_app_rest_tls_policy_test + SOURCES + tls_policy_test.cc + INCLUDES + ${CURL_SOURCE_DIR}/include + DEPENDS + firebase_app + firebase_rest_lib + libcurl +) + +if(MSVC) + foreach(rest_socket_test + firebase_app_rest_curl_io_loop_test + firebase_app_rest_transport_curl_test + firebase_app_rest_tls_policy_test) + target_compile_definitions(${rest_socket_test} + PRIVATE + -DWIN32_LEAN_AND_MEAN + CURL_STATICLIB=1 + ) + target_link_libraries(${rest_socket_test} + PRIVATE + ws2_32 + ) + endforeach() +endif() + #[[ # google3 Dependency: FLAGS_test_tmpdir, CHECK(), CHECK_EQ @@ -146,12 +200,4 @@ firebase_cpp_cc_test(firebase_app_rest_zlibwrapper_unittest firebase_rest_lib ) -# google3 Dependency: net/.../http2server, net/util/ports.h (net_util::PickUnusedPort()) -firebase_cpp_cc_test(firebase_app_rest_transport_curl_test - SOURCES - transport_curl_test.cc - DEPENDS - firebase_rest_lib -) - ]] diff --git a/app/rest/tests/curl_io_loop_test.cc b/app/rest/tests/curl_io_loop_test.cc new file mode 100644 index 0000000000..67f580b448 --- /dev/null +++ b/app/rest/tests/curl_io_loop_test.cc @@ -0,0 +1,354 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "app/rest/curl/curl_io_loop.h" + +#include +#include // NOLINT +#include +#include // NOLINT +#include + +#include "app/rest/tests/test_http_server.h" +#include "app/src/semaphore.h" +#include "curl/curl.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" + +namespace firebase { +namespace rest { +namespace curl { +namespace { + +using ::testing::ElementsAre; + +constexpr int kWaitTimeoutMs = 10000; + +// Blocks until the I/O thread has run everything posted before this call. +void Drain(CurlIoLoop& loop) { + Semaphore done(0); + loop.Post([&done]() { done.Post(); }); + ASSERT_TRUE(done.TimedWait(kWaitTimeoutMs)); +} + +// A Transfer that owns its own easy handle, fetches a URL, discards the body +// and records OnDone(). +class TestTransfer : public Transfer { + public: + explicit TestTransfer(const std::string& url) + : easy_(curl_easy_init()), + done_(0), + done_called_(false), + result_(CURLE_OK), + http_status_(0) { + curl_easy_setopt(easy_.get(), CURLOPT_URL, url.c_str()); + curl_easy_setopt(easy_.get(), CURLOPT_WRITEFUNCTION, &DiscardBody); + curl_easy_setopt(easy_.get(), CURLOPT_WRITEDATA, static_cast(this)); + } + + CURL* easy() override { return easy_.get(); } + + void OnDone(CURLcode result) override { + done_called_ = true; + result_ = result; + long status = 0; // NOLINT + curl_easy_getinfo(easy_.get(), CURLINFO_RESPONSE_CODE, &status); + http_status_ = static_cast(status); + on_done_thread_was_io_thread_ = CurlIoLoop::Get().IsOnIoThread(); + done_.Post(); + } + + bool WaitForDone(int timeout_ms = kWaitTimeoutMs) { + return done_.TimedWait(timeout_ms); + } + bool done_called() const { return done_called_.load(); } + CURLcode result() const { return result_; } + int http_status() const { return http_status_; } + bool on_done_thread_was_io_thread() const { + return on_done_thread_was_io_thread_; + } + + private: + static size_t DiscardBody(char*, size_t size, size_t nmemb, void*) { + return size * nmemb; + } + + UniqueEasy easy_; + Semaphore done_; + std::atomic done_called_; + CURLcode result_; + int http_status_; + bool on_done_thread_was_io_thread_ = false; +}; + +class CurlIoLoopTest : public ::testing::Test { + protected: + static void SetUpTestSuite() { + server_ = new test::TestHttpServer(); + ASSERT_TRUE(server_->Start()); + } + static void TearDownTestSuite() { + delete server_; + server_ = nullptr; + } + + void SetUp() override { CurlIoLoop::Acquire(); } + void TearDown() override { CurlIoLoop::Release(); } + + static test::TestHttpServer* server_; +}; + +test::TestHttpServer* CurlIoLoopTest::server_ = nullptr; + +TEST_F(CurlIoLoopTest, AcquireStartsThreadAndReleaseStopsIt) { + EXPECT_TRUE(CurlIoLoop::IsRunning()); + EXPECT_FALSE(CurlIoLoop::Get().IsOnIoThread()); +} + +TEST_F(CurlIoLoopTest, AcquireReleaseIsReentrant) { + // The fixture already holds one reference. + CurlIoLoop* first = &CurlIoLoop::Get(); + CurlIoLoop::Acquire(); + CurlIoLoop::Acquire(); + EXPECT_EQ(first, &CurlIoLoop::Get()); + CurlIoLoop::Release(); + EXPECT_TRUE(CurlIoLoop::IsRunning()); + EXPECT_EQ(first, &CurlIoLoop::Get()); + CurlIoLoop::Release(); + EXPECT_TRUE(CurlIoLoop::IsRunning()); + EXPECT_EQ(first, &CurlIoLoop::Get()); + + // Dropping the last reference really stops the thread... + CurlIoLoop::Release(); + EXPECT_FALSE(CurlIoLoop::IsRunning()); + // ...and re-acquiring re-initializes libcurl and starts a fresh loop + // (balanced by TearDown). + CurlIoLoop::Acquire(); + EXPECT_TRUE(CurlIoLoop::IsRunning()); + Drain(CurlIoLoop::Get()); + TestTransfer transfer(server_->BaseUrl() + "/"); + CurlIoLoop::Get().Post( + [&]() { ASSERT_EQ(CURLM_OK, CurlIoLoop::Get().Add(transfer)); }); + ASSERT_TRUE(transfer.WaitForDone()); + EXPECT_EQ(200, transfer.http_status()); +} + +TEST_F(CurlIoLoopTest, PostRunsOnIoThreadInFifoOrder) { + CurlIoLoop& loop = CurlIoLoop::Get(); + std::vector order; + std::atomic on_io_thread_count{0}; + const int kCount = 100; + for (int i = 0; i < kCount; ++i) { + loop.Post([&, i]() { + if (loop.IsOnIoThread()) on_io_thread_count++; + order.push_back(i); + }); + } + Drain(loop); + ASSERT_EQ(kCount, static_cast(order.size())); + for (int i = 0; i < kCount; ++i) EXPECT_EQ(i, order[i]); + EXPECT_EQ(kCount, on_io_thread_count.load()); +} + +TEST_F(CurlIoLoopTest, PostWakesTheThreadPromptly) { + CurlIoLoop& loop = CurlIoLoop::Get(); + // Let the loop settle into curl_multi_poll() first. + Drain(loop); + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + + int64_t worst_us = 0; + for (int i = 0; i < 5; ++i) { + Semaphore ran(0); + auto start = std::chrono::steady_clock::now(); + loop.Post([&ran]() { ran.Post(); }); + ASSERT_TRUE(ran.TimedWait(kWaitTimeoutMs)); + int64_t elapsed_us = std::chrono::duration_cast( + std::chrono::steady_clock::now() - start) + .count(); + if (elapsed_us > worst_us) worst_us = elapsed_us; + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + } + // Nominal latency is tens of microseconds; the loop's safety-net poll + // timeout is 1000 ms, so anything far below that proves the wakeup works. + // Keep the bound loose to stay robust on loaded CI machines. + EXPECT_LT(worst_us, 500 * 1000) << "Post() wake latency too high"; +} + +TEST_F(CurlIoLoopTest, PostFromIoThreadIsEnqueuedNotRunInline) { + CurlIoLoop& loop = CurlIoLoop::Get(); + std::vector events; + Semaphore done(0); + loop.Post([&]() { + events.push_back("outer-begin"); + loop.Post([&]() { + events.push_back("inner"); + done.Post(); + }); + events.push_back("outer-end"); + }); + ASSERT_TRUE(done.TimedWait(kWaitTimeoutMs)); + EXPECT_THAT(events, ElementsAre("outer-begin", "outer-end", "inner")); +} + +TEST_F(CurlIoLoopTest, AddRunsTransferAndDeliversOnDone) { + CurlIoLoop& loop = CurlIoLoop::Get(); + TestTransfer transfer(server_->BaseUrl() + "/"); + CURLMcode add_result = CURLM_LAST; + loop.Post([&]() { + add_result = loop.Add(transfer); + EXPECT_EQ(1u, loop.transfer_count()); + }); + ASSERT_TRUE(transfer.WaitForDone()); + EXPECT_EQ(CURLM_OK, add_result); + EXPECT_EQ(CURLE_OK, transfer.result()); + EXPECT_EQ(200, transfer.http_status()); + EXPECT_TRUE(transfer.on_done_thread_was_io_thread()); + loop.Post([&]() { EXPECT_EQ(0u, loop.transfer_count()); }); + Drain(loop); + EXPECT_EQ("GET", server_->last_request().method); +} + +TEST_F(CurlIoLoopTest, AddSameHandleTwiceFails) { + CurlIoLoop& loop = CurlIoLoop::Get(); + TestTransfer transfer(server_->BaseUrl() + "/slow?ms=300"); + CURLMcode second = CURLM_OK; + loop.Post([&]() { + ASSERT_EQ(CURLM_OK, loop.Add(transfer)); + second = loop.Add(transfer); + }); + ASSERT_TRUE(transfer.WaitForDone()); + EXPECT_EQ(CURLM_ADDED_ALREADY, second); + EXPECT_EQ(CURLE_OK, transfer.result()); +} + +TEST_F(CurlIoLoopTest, RemoveStopsTransferWithoutOnDone) { + CurlIoLoop& loop = CurlIoLoop::Get(); + TestTransfer transfer(server_->BaseUrl() + "/slow?ms=5000"); + loop.Post([&]() { ASSERT_EQ(CURLM_OK, loop.Add(transfer)); }); + Drain(loop); + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + + bool removed = false; + bool removed_again = true; + loop.Post([&]() { + removed = loop.Remove(transfer); + removed_again = loop.Remove(transfer); + EXPECT_EQ(0u, loop.transfer_count()); + }); + Drain(loop); + EXPECT_TRUE(removed); + EXPECT_FALSE(removed_again); + EXPECT_FALSE(transfer.done_called()); + // Give the loop a chance to misbehave. + EXPECT_FALSE(transfer.WaitForDone(200)); +} + +TEST_F(CurlIoLoopTest, ManyConcurrentTransfersComplete) { + CurlIoLoop& loop = CurlIoLoop::Get(); + const int kCount = 16; + std::vector> transfers; + for (int i = 0; i < kCount; ++i) { + transfers.emplace_back( + new TestTransfer(server_->BaseUrl() + "/slow?ms=200")); + } + auto start = std::chrono::steady_clock::now(); + loop.Post([&]() { + for (auto& t : transfers) ASSERT_EQ(CURLM_OK, loop.Add(*t)); + }); + for (auto& t : transfers) { + ASSERT_TRUE(t->WaitForDone()); + EXPECT_EQ(CURLE_OK, t->result()); + EXPECT_EQ(200, t->http_status()); + } + auto elapsed_ms = std::chrono::duration_cast( + std::chrono::steady_clock::now() - start) + .count(); + // Serialized they would take >= 16 * 200 ms. + EXPECT_LT(elapsed_ms, 16 * 200); +} + +TEST_F(CurlIoLoopTest, PostIfRunningHandlesStoppedAndRunningStates) { + EXPECT_FALSE(CurlIoLoop::IsCurrentThreadIoThread()); + std::atomic ran_on_io_thread{false}; + Semaphore ran(0); + EXPECT_TRUE(CurlIoLoop::PostIfRunning([&]() { + ran_on_io_thread = CurlIoLoop::IsCurrentThreadIoThread(); + ran.Post(); + })); + ASSERT_TRUE(ran.TimedWait(kWaitTimeoutMs)); + EXPECT_TRUE(ran_on_io_thread.load()); + + // Stop the loop completely; PostIfRunning() must return false without + // asserting or invoking the callback. + CurlIoLoop::Release(); + EXPECT_FALSE(CurlIoLoop::IsRunning()); + std::atomic ran_when_stopped{false}; + EXPECT_FALSE(CurlIoLoop::PostIfRunning([&]() { ran_when_stopped = true; })); + EXPECT_FALSE(ran_when_stopped.load()); + + // Re-acquire so TearDown() balances. + CurlIoLoop::Acquire(); +} + +TEST_F(CurlIoLoopTest, ShutdownAbortsInFlightTransfer) { + CurlIoLoop& loop = CurlIoLoop::Get(); + TestTransfer transfer(server_->BaseUrl() + "/slow?ms=10000"); + loop.Post([&]() { ASSERT_EQ(CURLM_OK, loop.Add(transfer)); }); + Drain(loop); + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + EXPECT_FALSE(transfer.done_called()); + + // Drop the fixture's (only) reference: the loop must abort the transfer on + // the I/O thread *before* Release() returns. + auto start = std::chrono::steady_clock::now(); + CurlIoLoop::Release(); + auto elapsed_ms = std::chrono::duration_cast( + std::chrono::steady_clock::now() - start) + .count(); + EXPECT_FALSE(CurlIoLoop::IsRunning()); + EXPECT_TRUE(transfer.done_called()); + EXPECT_EQ(CURLE_ABORTED_BY_CALLBACK, transfer.result()); + EXPECT_LT(elapsed_ms, 5000); // Did not wait for the slow server. + + // Re-acquire so TearDown's Release() balances. + CurlIoLoop::Acquire(); +} + +TEST_F(CurlIoLoopTest, PostedWorkDuringShutdownStillRuns) { + // Functions posted by OnDone() during shutdown must still be executed. + CurlIoLoop& loop = CurlIoLoop::Get(); + class PostingTransfer : public TestTransfer { + public: + explicit PostingTransfer(const std::string& url) : TestTransfer(url) {} + void OnDone(CURLcode result) override { + CurlIoLoop::Get().Post([this]() { follow_up_ran = true; }); + TestTransfer::OnDone(result); + } + std::atomic follow_up_ran{false}; + }; + PostingTransfer transfer(server_->BaseUrl() + "/slow?ms=10000"); + loop.Post([&]() { ASSERT_EQ(CURLM_OK, loop.Add(transfer)); }); + Drain(loop); + CurlIoLoop::Release(); + EXPECT_TRUE(transfer.done_called()); + EXPECT_TRUE(transfer.follow_up_ran.load()); + CurlIoLoop::Acquire(); +} + +} // namespace +} // namespace curl +} // namespace rest +} // namespace firebase diff --git a/app/rest/tests/response_test.cc b/app/rest/tests/response_test.cc index 7a9956f9b6..b792b432cc 100644 --- a/app/rest/tests/response_test.cc +++ b/app/rest/tests/response_test.cc @@ -49,6 +49,79 @@ TEST(ResponseTest, ProcessStatusLine) { EXPECT_EQ(302, response.status()); } +TEST(ResponseTest, ProcessHttp2AndReasonlessStatusLines) { + Response response; + EXPECT_EQ(0, response.status()); + + // HTTP/2 status lines synthesized by libcurl have no minor version and often + // omit the reason-phrase. + ProcessHeader("HTTP/2 200 \r\n", &response); + EXPECT_EQ(200, response.status()); + + ProcessHeader("HTTP/2 204\r\n", &response); + EXPECT_EQ(204, response.status()); + + ProcessHeader("HTTP/2.0 404\r\n", &response); + EXPECT_EQ(404, response.status()); + + ProcessHeader("HTTP/3 201\r\n", &response); + EXPECT_EQ(201, response.status()); + + // HTTP/1.1 with no reason-phrase (valid per RFC 9112 section 4). + ProcessHeader("HTTP/1.1 503\r\n", &response); + EXPECT_EQ(503, response.status()); + + // Malformed status lines must not overwrite the previously parsed status. + ProcessHeader("HTTP/ 200 OK\r\n", &response); + EXPECT_EQ(503, response.status()); + ProcessHeader("HTTP/1. 200 OK\r\n", &response); + EXPECT_EQ(503, response.status()); + ProcessHeader("HTTP/2 99\r\n", &response); + EXPECT_EQ(503, response.status()); + ProcessHeader("NOTHTTP/2 200\r\n", &response); + EXPECT_EQ(503, response.status()); +} + +TEST(ResponseTest, InterimContinueFollowedByFinalHttp2Response) { + Response response; + ProcessHeader("HTTP/1.1 100 Continue\r\n", &response); + EXPECT_EQ(100, response.status()); + ProcessHeader("\r\n", &response); + EXPECT_TRUE(response.header_completed()); + + // Final status line resets header_completed until the final CRLF arrives. + ProcessHeader("HTTP/2 200\r\n", &response); + EXPECT_EQ(200, response.status()); + EXPECT_FALSE(response.header_completed()); + ProcessHeader("content-type: application/json\r\n", &response); + ProcessHeader("\r\n", &response); + EXPECT_TRUE(response.header_completed()); +} + +TEST(ResponseTest, CaseInsensitiveHeadersAndHttp2LowercaseDate) { + Response response; + EXPECT_EQ(nullptr, response.GetHeader(nullptr)); + + // HTTP/2 headers are always lowercase (RFC 9113 section 8.2). + ProcessHeader("content-type: application/json\r\n", &response); + ProcessHeader("etag: \"v1\"\r\n", &response); + ProcessHeader("date: Wed, 05 Jul 2017 15:55:19 GMT\r\n", &response); + + EXPECT_STREQ("application/json", response.GetHeader("Content-Type")); + EXPECT_STREQ("application/json", response.GetHeader("content-type")); + EXPECT_STREQ("application/json", response.GetHeader("CONTENT-TYPE")); + EXPECT_STREQ("\"v1\"", response.GetHeader("ETag")); + EXPECT_STREQ("\"v1\"", response.GetHeader("etag")); + + // Redirect or final response overwriting an earlier header of different case. + ProcessHeader("ETag: \"v2\"\r\n", &response); + EXPECT_STREQ("\"v2\"", response.GetHeader("etag")); + EXPECT_STREQ("\"v2\"", response.GetHeader("ETag")); + + response.MarkCompleted(); + EXPECT_EQ(1499270119, response.fetch_time()); +} + TEST(ResponseTest, ProcessHeaderEnding) { Response response; EXPECT_FALSE(response.header_completed()); diff --git a/app/rest/tests/test_http_server.h b/app/rest/tests/test_http_server.h new file mode 100644 index 0000000000..4dffcfb9e7 --- /dev/null +++ b/app/rest/tests/test_http_server.h @@ -0,0 +1,562 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// A tiny, dependency-free, blocking HTTP/1.1 server bound to 127.0.0.1 for +// exercising the libcurl transport in unit tests. It follows the same +// POSIX / Winsock portability pattern as TestWebSocketEchoServer in +// database/tests/desktop/connection/web_socket_client_impl_test.cc. +// +// Behavior (every response carries "Server: TestHttpServer" and +// "Connection: close"): +// GET -> 200, body "test" +// POST -> 200, body = request body (Content-Length or +// chunked request bodies are both accepted) +// -> 200, body = the method name +// /slow?ms=N -> like the above but sleeps N ms (default 2000) +// before replying +// /large?size=N -> 200, body of N bytes ('x') +// /status/NNN -> status NNN with an empty body +// +// The server records every request it has fully read so tests can assert on +// what the client actually sent. + +#ifndef FIREBASE_APP_REST_TESTS_TEST_HTTP_SERVER_H_ +#define FIREBASE_APP_REST_TESTS_TEST_HTTP_SERVER_H_ + +#include +#include // NOLINT +#include +#include +#include +#include +#include +#include // NOLINT +#include +#include // NOLINT +#include + +#include "app/src/include/firebase/internal/platform.h" + +#if FIREBASE_PLATFORM_WINDOWS +#include +#include +#else +#include +#include +#include +#include +#include +#include +#include +#endif + +namespace firebase { +namespace rest { +namespace test { + +#if FIREBASE_PLATFORM_WINDOWS +typedef SOCKET SocketHandle; +const SocketHandle kInvalidSocket = INVALID_SOCKET; +inline void CloseSocket(SocketHandle s) { closesocket(s); } +inline bool LastSocketErrorWasTimeout() { + int err = WSAGetLastError(); + return err == WSAETIMEDOUT || err == WSAEWOULDBLOCK; +} +#else +typedef int SocketHandle; +const SocketHandle kInvalidSocket = -1; +inline void CloseSocket(SocketHandle s) { close(s); } +inline bool LastSocketErrorWasTimeout() { + return errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR; +} +#endif + +// Process-wide socket subsystem initialization (WSAStartup on Windows, SIGPIPE +// suppression elsewhere). Safe to construct many times. +class SocketEnvironment { + public: + SocketEnvironment() { +#if FIREBASE_PLATFORM_WINDOWS + WSADATA wsa_data; + WSAStartup(MAKEWORD(2, 2), &wsa_data); +#else + signal(SIGPIPE, SIG_IGN); +#endif + } + ~SocketEnvironment() { +#if FIREBASE_PLATFORM_WINDOWS + WSACleanup(); +#endif + } +}; + +inline void SetSocketRecvTimeout(SocketHandle sock, int timeout_ms) { +#if FIREBASE_PLATFORM_WINDOWS + DWORD timeout = static_cast(timeout_ms); + setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, + reinterpret_cast(&timeout), sizeof(timeout)); +#else + timeval tv; + tv.tv_sec = timeout_ms / 1000; + tv.tv_usec = (timeout_ms % 1000) * 1000; + setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); +#endif +} + +inline bool SendAll(SocketHandle sock, const void* data, size_t len) { + const char* ptr = static_cast(data); + int flags = 0; +#ifdef MSG_NOSIGNAL + flags |= MSG_NOSIGNAL; +#endif + while (len > 0) { + auto sent = send(sock, ptr, static_cast(len), flags); + if (sent <= 0) return false; + ptr += sent; + len -= static_cast(sent); + } + return true; +} + +// A pair of connected loopback TCP sockets, for tests that need a real file +// descriptor to wait on. +class TcpSocketPair { + public: + TcpSocketPair() : a_(kInvalidSocket), b_(kInvalidSocket) { + SocketHandle listener = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + if (listener == kInvalidSocket) return; + sockaddr_in addr; + std::memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + addr.sin_port = 0; + if (bind(listener, reinterpret_cast(&addr), sizeof(addr)) != 0 || + listen(listener, 1) != 0) { + CloseSocket(listener); + return; + } + socklen_t addr_len = sizeof(addr); + getsockname(listener, reinterpret_cast(&addr), &addr_len); + a_ = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + if (a_ != kInvalidSocket && + connect(a_, reinterpret_cast(&addr), sizeof(addr)) == 0) { + b_ = accept(listener, nullptr, nullptr); + } + CloseSocket(listener); + if (b_ == kInvalidSocket && a_ != kInvalidSocket) { + CloseSocket(a_); + a_ = kInvalidSocket; + } + } + ~TcpSocketPair() { + if (a_ != kInvalidSocket) CloseSocket(a_); + if (b_ != kInvalidSocket) CloseSocket(b_); + } + bool valid() const { return a_ != kInvalidSocket && b_ != kInvalidSocket; } + SocketHandle a() const { return a_; } + SocketHandle b() const { return b_; } + + private: + SocketHandle a_; + SocketHandle b_; +}; + +struct RecordedRequest { + std::string method; + std::string path; + std::map headers; // lower-cased names + std::string body; +}; + +class TestHttpServer { + public: + static const char* kServerName() { return "TestHttpServer"; } + + TestHttpServer() + : port_(0), + run_(false), + listen_sock_(kInvalidSocket), + accept_thread_(nullptr), + request_count_(0) {} + + ~TestHttpServer() { Stop(); } + + // Binds, listens and starts accepting. Returns false on failure. + bool Start() { + listen_sock_ = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + if (listen_sock_ == kInvalidSocket) return false; + + int opt = 1; + setsockopt(listen_sock_, SOL_SOCKET, SO_REUSEADDR, + reinterpret_cast(&opt), sizeof(opt)); + + sockaddr_in addr; + std::memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + addr.sin_port = 0; + if (bind(listen_sock_, reinterpret_cast(&addr), sizeof(addr)) != + 0 || + listen(listen_sock_, 128) != 0) { + CloseSocket(listen_sock_); + listen_sock_ = kInvalidSocket; + return false; + } + sockaddr_in bound_addr; + socklen_t addr_len = sizeof(bound_addr); + if (getsockname(listen_sock_, reinterpret_cast(&bound_addr), + &addr_len) != 0) { + CloseSocket(listen_sock_); + listen_sock_ = kInvalidSocket; + return false; + } + port_.store(ntohs(bound_addr.sin_port)); + run_.store(true); + accept_thread_ = new std::thread([this]() { RunAcceptLoop(); }); + return true; + } + + void Stop() { + run_.store(false); + if (listen_sock_ != kInvalidSocket) { + CloseSocket(listen_sock_); + listen_sock_ = kInvalidSocket; + } + if (accept_thread_ != nullptr) { + accept_thread_->join(); + delete accept_thread_; + accept_thread_ = nullptr; + } + std::vector workers; + { + std::lock_guard lock(mutex_); + workers.swap(workers_); + } + for (Worker* worker : workers) { + worker->thread.join(); + delete worker; + } + } + + int port() const { return port_.load(); } + + std::string BaseUrl() const { + return "http://127.0.0.1:" + std::to_string(port()); + } + + int request_count() const { return request_count_.load(); } + + // Copy of the most recently *completed* request, or an empty one. + RecordedRequest last_request() const { + std::lock_guard lock(mutex_); + return last_request_; + } + + private: + static bool RecvSome(SocketHandle sock, std::string* out, + const std::atomic& run) { + char buffer[4096]; + for (;;) { + auto r = recv(sock, buffer, sizeof(buffer), 0); + if (r > 0) { + out->append(buffer, static_cast(r)); + return true; + } + if (r == 0) return false; // Peer closed. + if (LastSocketErrorWasTimeout() && run.load()) continue; + return false; + } + } + + static std::string ToLower(std::string s) { + for (char& c : s) { + if (c >= 'A' && c <= 'Z') c = static_cast(c - 'A' + 'a'); + } + return s; + } + + static std::string Trim(const std::string& s) { + size_t start = s.find_first_not_of(" \t"); + if (start == std::string::npos) return ""; + size_t end = s.find_last_not_of(" \t\r"); + return s.substr(start, end - start + 1); + } + + // Parses "name=value" query parameters out of path. Returns path sans query. + static std::string SplitQuery(const std::string& target, + std::map* query) { + size_t q = target.find('?'); + if (q == std::string::npos) return target; + std::string rest = target.substr(q + 1); + size_t pos = 0; + while (pos <= rest.size()) { + size_t amp = rest.find('&', pos); + std::string pair = rest.substr( + pos, amp == std::string::npos ? std::string::npos : amp - pos); + size_t eq = pair.find('='); + if (eq != std::string::npos) { + (*query)[pair.substr(0, eq)] = pair.substr(eq + 1); + } else if (!pair.empty()) { + (*query)[pair] = ""; + } + if (amp == std::string::npos) break; + pos = amp + 1; + } + return target.substr(0, q); + } + + // Reads one request (head + body). Returns false if the connection was + // closed or timed out before a complete request arrived. + bool ReadRequest(SocketHandle sock, RecordedRequest* request) { + std::string data; + size_t head_end; + while ((head_end = data.find("\r\n\r\n")) == std::string::npos) { + if (data.size() > 64 * 1024) return false; + if (!RecvSome(sock, &data, run_)) return false; + } + std::string head = data.substr(0, head_end); + std::string body = data.substr(head_end + 4); + + // Request line. + size_t line_end = head.find("\r\n"); + std::string request_line = head.substr(0, line_end); + size_t sp1 = request_line.find(' '); + size_t sp2 = request_line.find(' ', sp1 + 1); + if (sp1 == std::string::npos || sp2 == std::string::npos) return false; + request->method = request_line.substr(0, sp1); + request->path = request_line.substr(sp1 + 1, sp2 - sp1 - 1); + + // Headers. + size_t pos = line_end == std::string::npos ? head.size() : line_end + 2; + while (pos < head.size()) { + size_t next = head.find("\r\n", pos); + std::string line = head.substr( + pos, next == std::string::npos ? std::string::npos : next - pos); + size_t colon = line.find(':'); + if (colon != std::string::npos) { + request->headers[ToLower(Trim(line.substr(0, colon)))] = + Trim(line.substr(colon + 1)); + } + if (next == std::string::npos) break; + pos = next + 2; + } + + // libcurl sends "Expect: 100-continue" for uploads of unknown length and + // waits up to a second for the interim response before sending the body. + auto expect = request->headers.find("expect"); + if (expect != request->headers.end() && + ToLower(expect->second) == "100-continue") { + static const char kContinue[] = "HTTP/1.1 100 Continue\r\n\r\n"; + SendAll(sock, kContinue, sizeof(kContinue) - 1); + } + + // Body. + auto te = request->headers.find("transfer-encoding"); + auto cl = request->headers.find("content-length"); + if (te != request->headers.end() && + ToLower(te->second).find("chunked") != std::string::npos) { + // Chunked: read until the terminating zero-length chunk. + std::string decoded; + for (;;) { + size_t crlf; + while ((crlf = body.find("\r\n")) == std::string::npos) { + if (!RecvSome(sock, &body, run_)) return false; + } + size_t chunk_size = static_cast( + std::strtoul(body.substr(0, crlf).c_str(), nullptr, 16)); + body.erase(0, crlf + 2); + if (chunk_size == 0) { + // Consume the trailer terminator if present. + while (body.find("\r\n") == std::string::npos) { + if (!RecvSome(sock, &body, run_)) break; + } + break; + } + while (body.size() < chunk_size + 2) { + if (!RecvSome(sock, &body, run_)) return false; + } + decoded.append(body, 0, chunk_size); + body.erase(0, chunk_size + 2); + } + request->body = decoded; + } else if (cl != request->headers.end()) { + size_t length = + static_cast(std::strtoul(cl->second.c_str(), nullptr, 10)); + while (body.size() < length) { + if (!RecvSome(sock, &body, run_)) return false; + } + request->body = body.substr(0, length); + } else { + request->body.clear(); + } + return true; + } + + // Sleeps in slices so Stop() stays prompt. + void SleepWhileRunning(int ms) { + auto deadline = + std::chrono::steady_clock::now() + std::chrono::milliseconds(ms); + while (run_.load() && std::chrono::steady_clock::now() < deadline) { + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + } + } + + void HandleClient(SocketHandle sock) { + SetSocketRecvTimeout(sock, 200); + RecordedRequest request; + if (!ReadRequest(sock, &request)) return; + + std::map query; + std::string path = SplitQuery(request.path, &query); + + { + std::lock_guard lock(mutex_); + last_request_ = request; + } + request_count_.fetch_add(1); + + int status = 200; + std::string body; + if (path == "/slow") { + int ms = 2000; + auto it = query.find("ms"); + if (it != query.end()) ms = std::atoi(it->second.c_str()); + SleepWhileRunning(ms); + if (!run_.load()) return; + } + if (path == "/large") { + size_t size = 1024 * 1024; + auto it = query.find("size"); + if (it != query.end()) { + size = + static_cast(std::strtoul(it->second.c_str(), nullptr, 10)); + } + body.assign(size, 'x'); + } else if (path.compare(0, 8, "/status/") == 0) { + status = std::atoi(path.c_str() + 8); + } else if (request.method == "GET") { + body = "test"; + } else if (request.method == "POST") { + body = request.body; + } else { + body = request.method; + } + + std::string response = "HTTP/1.1 " + std::to_string(status) + " " + + ReasonPhrase(status) + + "\r\n" + "Server: " + + std::string(kServerName()) + + "\r\n" + "Content-Type: text/plain\r\n" + "Content-Length: " + + std::to_string(body.size()) + + "\r\n" + "Connection: close\r\n" + "\r\n"; + response += body; + SendAll(sock, response.data(), response.size()); + } + + static const char* ReasonPhrase(int status) { + switch (status) { + case 200: + return "OK"; + case 204: + return "No Content"; + case 400: + return "Bad Request"; + case 404: + return "Not Found"; + case 500: + return "Internal Server Error"; + default: + return "Status"; + } + } + + struct Worker { + std::thread thread; + std::atomic done{false}; + }; + + void ReapFinishedWorkersLocked() { + size_t out = 0; + for (size_t i = 0; i < workers_.size(); ++i) { + Worker* w = workers_[i]; + if (w->done.load(std::memory_order_acquire)) { + w->thread.join(); + delete w; + } else { + workers_[out++] = w; + } + } + workers_.resize(out); + } + + void RunAcceptLoop() { + while (run_.load()) { + { + std::lock_guard lock(mutex_); + ReapFinishedWorkersLocked(); + } + SocketHandle lsock = listen_sock_; + if (lsock == kInvalidSocket) break; + fd_set read_fds; + FD_ZERO(&read_fds); + FD_SET(lsock, &read_fds); + timeval tv; + tv.tv_sec = 0; + tv.tv_usec = 50 * 1000; // 50ms + int sel = + select(static_cast(lsock + 1), &read_fds, nullptr, nullptr, &tv); + if (sel <= 0 || !run_.load()) continue; + + sockaddr_in client_addr; + socklen_t client_len = sizeof(client_addr); + SocketHandle client_sock = + accept(lsock, reinterpret_cast(&client_addr), &client_len); + if (client_sock == kInvalidSocket) continue; + + // One thread per connection so a /slow request never blocks others. + Worker* worker = new Worker(); + worker->thread = std::thread([this, client_sock, worker]() { + HandleClient(client_sock); + CloseSocket(client_sock); + worker->done.store(true, std::memory_order_release); + }); + std::lock_guard lock(mutex_); + ReapFinishedWorkersLocked(); + workers_.push_back(worker); + } + } + + SocketEnvironment socket_environment_; + std::atomic port_; + std::atomic run_; + SocketHandle listen_sock_; + std::thread* accept_thread_; + mutable std::mutex mutex_; + std::vector workers_; + RecordedRequest last_request_; + std::atomic request_count_; +}; + +} // namespace test +} // namespace rest +} // namespace firebase + +#endif // FIREBASE_APP_REST_TESTS_TEST_HTTP_SERVER_H_ diff --git a/app/rest/tests/tls_policy_test.cc b/app/rest/tests/tls_policy_test.cc new file mode 100644 index 0000000000..f1d349b2a7 --- /dev/null +++ b/app/rest/tests/tls_policy_test.cc @@ -0,0 +1,91 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "app/rest/curl/tls_policy.h" + +#include + +#include "app/rest/curl/curl_io_loop.h" +#include "curl/curl.h" +#include "gtest/gtest.h" + +#if defined(__linux__) +#include +#include +#endif + +namespace firebase { +namespace rest { +namespace curl { +namespace { + +class TlsPolicyTest : public ::testing::Test { + protected: + void SetUp() override { (void)curl_global_init(CURL_GLOBAL_ALL); } + void TearDown() override { curl_global_cleanup(); } +}; + +TEST_F(TlsPolicyTest, ApplyTlsPolicySucceedsAndSurvivesEasyReset) { + UniqueEasy easy(curl_easy_init()); + ASSERT_NE(nullptr, easy.get()); + EXPECT_EQ(CURLE_OK, ApplyTlsPolicy(easy.get())); + + curl_easy_reset(easy.get()); + EXPECT_EQ(CURLE_OK, ApplyTlsPolicy(easy.get())); +} + +TEST_F(TlsPolicyTest, UniqueHandlesAreSafeOnNullAndSListChaining) { + UniqueEasy null_easy(nullptr); + UniqueMulti null_multi(nullptr); + UniqueSList headers(nullptr); + + headers.reset(curl_slist_append(headers.release(), "X-First: 1")); + headers.reset(curl_slist_append(headers.release(), "X-Second: 2")); + ASSERT_NE(nullptr, headers.get()); + EXPECT_STREQ("X-First: 1", headers->data); + ASSERT_NE(nullptr, headers->next); + EXPECT_STREQ("X-Second: 2", headers->next->data); + EXPECT_EQ(nullptr, headers->next->next); +} + +#if defined(__linux__) +TEST_F(TlsPolicyTest, LinuxRuntimeCaPathsAreReadableWhenPresent) { + const char* bundle = LinuxCaBundlePath(); + if (bundle != nullptr) { + struct stat st; + ASSERT_EQ(0, stat(bundle, &st)) << bundle; + EXPECT_TRUE(S_ISREG(st.st_mode)) << bundle; + EXPECT_EQ(0, access(bundle, R_OK)) << bundle; + } + + const char* capath = LinuxCaDirectoryPath(); + if (capath != nullptr) { + struct stat st; + ASSERT_EQ(0, stat(capath, &st)) << capath; + EXPECT_TRUE(S_ISDIR(st.st_mode)) << capath; + EXPECT_EQ(0, access(capath, R_OK | X_OK)) << capath; + } + + // On any standard Linux CI/developer machine at least one of the two trust + // store locations is populated. + EXPECT_TRUE(bundle != nullptr || capath != nullptr); +} +#endif // defined(__linux__) + +} // namespace +} // namespace curl +} // namespace rest +} // namespace firebase diff --git a/app/rest/tests/transport_curl_test.cc b/app/rest/tests/transport_curl_test.cc index ff1bfab5b1..cb0fbe0694 100644 --- a/app/rest/tests/transport_curl_test.cc +++ b/app/rest/tests/transport_curl_test.cc @@ -14,121 +14,145 @@ * limitations under the License. */ -// This is a large test that starts a local http server and tests transport_curl -// with actual http connection. +// Exercises TransportCurl against a real localhost HTTP server (see +// test_http_server.h): sync and async requests, Controller pause / resume / +// cancel, timeouts, concurrency and teardown ordering. #include "app/rest/transport_curl.h" +#include +#include // NOLINT #include -#include +#include +#include #include +#include // NOLINT +#include -#include "absl/strings/str_format.h" -#include "absl/synchronization/mutex.h" -#include "absl/time/time.h" +#if !defined(_WIN32) +#include +#include +#include +#include +#endif + +#include "app/rest/controller_curl.h" +#include "app/rest/controller_interface.h" +#include "app/rest/curl/curl_io_loop.h" #include "app/rest/request.h" #include "app/rest/response.h" +#include "app/rest/tests/test_http_server.h" +#include "app/rest/util.h" +#include "app/src/semaphore.h" #include "gmock/gmock.h" #include "gtest/gtest.h" -#include "net/http2/server/lib/public/httpserver2.h" -#include "net/util/ports.h" -#include "util/task/status.h" namespace firebase { namespace rest { +namespace { -const char* kServerVersion = "HTTP server for test"; - -void UriHandler(HTTPServerRequest* request) { - if (request->http_method() == "GET") { - request->output()->WriteString("test"); - request->Reply(); - LOG(INFO) << "Sent response for GET"; - } else if (request->http_method() == "POST" && - request->input_headers()->HeaderIs("Content-Type", - "application/json")) { - request->output()->WriteString(request->input()->ToString()); - request->Reply(); - LOG(INFO) << "Sent response for POST"; - } else { - FAIL(); - } -} - -const absl::Duration kTimeoutSeconds = absl::Seconds(10); +constexpr int kWaitTimeoutMs = 10000; +// A Response that lets the test block until the transport has marked it +// completed or failed. class TestResponse : public Response { public: + TestResponse() : done_(0), completed_calls_(0), failed_calls_(0) {} + void MarkCompleted() override { - absl::MutexLock lock(&mutex_); Response::MarkCompleted(); + completed_calls_++; + completion_thread_was_io_thread_ = + curl::CurlIoLoop::IsCurrentThreadIoThread(); + done_.Post(); + } + + void MarkFailed() override { + Response::MarkFailed(); + failed_calls_++; + done_.Post(); + } + + // Waits until MarkCompleted() or MarkFailed() has been called. + bool Wait(int timeout_ms = kWaitTimeoutMs) { + return done_.TimedWait(timeout_ms); } - void Wait() { - absl::MutexLock lock(&mutex_); - mutex_.AwaitWithTimeout( - absl::Condition( - [](void* userdata) -> bool { - auto* response = static_cast(userdata); - return response->header_completed() && response->body_completed(); - }, - this), - kTimeoutSeconds); + int completed_calls() const { return completed_calls_.load(); } + int failed_calls() const { return failed_calls_.load(); } + bool completion_thread_was_io_thread() const { + return completion_thread_was_io_thread_; } private: - absl::Mutex mutex_; + Semaphore done_; + std::atomic completed_calls_; + std::atomic failed_calls_; + bool completion_thread_was_io_thread_ = false; }; -class TransportCurlTest : public testing::Test { +// A Request whose upload size is unknown up front, forcing +// "Transfer-Encoding: chunked" when streamed. +class ChunkedRequest : public Request { + public: + explicit ChunkedRequest(const std::string& body) : body_(body), offset_(0) { + options_.stream_post_fields = true; + } + size_t GetPostFieldsSize() const override { return ~static_cast(0); } + size_t ReadBody(char* buffer, size_t length, bool* abort) override { + *abort = false; + size_t remaining = body_.size() - offset_; + size_t to_copy = remaining < length ? remaining : length; + // Trickle the body out to make sure multiple read callbacks happen. + if (to_copy > 7) to_copy = 7; + std::memcpy(buffer, body_.data() + offset_, to_copy); + offset_ += to_copy; + return to_copy; + } + + private: + std::string body_; + size_t offset_; +}; + +class TransportCurlTest : public ::testing::Test { protected: static void SetUpTestSuite() { - InitTransportCurl(); - // Start a local http server for testing the http request. - // Pick up a port. - std::string error; // PickUnusedPort actually asks for google3 string. - TransportCurlTest::port_ = net_util::PickUnusedPort(&error); - CHECK_GE(TransportCurlTest::port_, 0) << error; - LOG(INFO) << "Auto selected port " << port_ << " for test http server"; - // Create a new server. - std::unique_ptr options( - new net_http2::HTTPServer2::EventModeOptions()); - options->SetVersion(kServerVersion); - options->SetDataVersion("data_1.0"); - options->SetServerType("server"); - options->AddPort(TransportCurlTest::port_); - options->SetWindowSizesAndLatency(0, 0, true); - auto creation_status = net_http2::HTTPServer2::CreateEventDrivenModeServer( - nullptr /* event manager */, std::move(options)); - CHECK_OK(creation_status.status()); - // Register URI handler and start serving. - TransportCurlTest::server_ = creation_status.value().release(); - ABSL_DIE_IF_NULL(TransportCurlTest::server_) - ->RegisterHandler("*", NewPermanentCallback(&UriHandler)); - CHECK_OK(TransportCurlTest::server_->StartAcceptingRequests()); - LOG(INFO) << "Local HTTP server is ready to accept request"; + server_ = new test::TestHttpServer(); + ASSERT_TRUE(server_->Start()); } static void TearDownTestSuite() { - TransportCurlTest::server_->TerminateServer(); - delete TransportCurlTest::server_; - TransportCurlTest::server_ = nullptr; - CleanupTransportCurl(); + delete server_; + server_ = nullptr; } - static int32_t port_; - static net_http2::HTTPServer2* server_; + + void SetUp() override { InitTransportCurl(); } + void TearDown() override { CleanupTransportCurl(); } + + static std::string Url(const char* path) { return server_->BaseUrl() + path; } + + static test::TestHttpServer* server_; }; -int32_t TransportCurlTest::port_; -net_http2::HTTPServer2* TransportCurlTest::server_; +test::TestHttpServer* TransportCurlTest::server_ = nullptr; -TEST_F(TransportCurlTest, TestGlobalInitAndCleanup) { +TEST_F(TransportCurlTest, GlobalInitAndCleanupAreRefCounted) { + curl::CurlIoLoop* loop = &curl::CurlIoLoop::Get(); + InitTransportCurl(); InitTransportCurl(); CleanupTransportCurl(); + EXPECT_EQ(loop, &curl::CurlIoLoop::Get()); + CleanupTransportCurl(); + EXPECT_EQ(loop, &curl::CurlIoLoop::Get()); + EXPECT_TRUE(curl::CurlIoLoop::IsRunning()); } -TEST_F(TransportCurlTest, TestCreation) { TransportCurl curl; } +TEST_F(TransportCurlTest, Creation) { + TransportCurl curl; + EXPECT_FALSE(curl.is_async()); +} -TEST_F(TransportCurlTest, TestHttpGet) { +TEST_F(TransportCurlTest, HttpGetSync) { Request request; request.set_verbose(true); TestResponse response; @@ -138,44 +162,757 @@ TEST_F(TransportCurlTest, TestHttpGet) { EXPECT_EQ(nullptr, response.GetHeader("Server")); EXPECT_STREQ("", response.GetBody()); - const std::string& url = - absl::StrFormat("http://localhost:%d", TransportCurlTest::port_); - request.set_url(url.c_str()); + request.set_url(Url("/").c_str()); TransportCurl curl; curl.Perform(request, &response); - response.Wait(); + // Synchronous mode: everything is already there when Perform() returns. EXPECT_EQ(200, response.status()); EXPECT_TRUE(response.header_completed()); EXPECT_TRUE(response.body_completed()); - EXPECT_STREQ(kServerVersion, response.GetHeader("Server")); + EXPECT_STREQ(test::TestHttpServer::kServerName(), + response.GetHeader("Server")); + EXPECT_STREQ(test::TestHttpServer::kServerName(), + response.GetHeader("server")); EXPECT_STREQ("test", response.GetBody()); + EXPECT_TRUE(request.completed()); + EXPECT_EQ(1, response.completed_calls()); + EXPECT_EQ(0, response.failed_calls()); + EXPECT_TRUE(response.completion_thread_was_io_thread()); } -TEST_F(TransportCurlTest, TestHttpPost) { +TEST_F(TransportCurlTest, HttpPostJsonEcho) { Request request; request.set_verbose(true); TestResponse response; - EXPECT_EQ(0, response.status()); - EXPECT_FALSE(response.header_completed()); - EXPECT_FALSE(response.body_completed()); - EXPECT_EQ(nullptr, response.GetHeader("Server")); - EXPECT_STREQ("", response.GetBody()); - const std::string& url = - absl::StrFormat("http://localhost:%d", TransportCurlTest::port_); - request.set_url(url.c_str()); + request.set_url(Url("/echo").c_str()); request.set_method("POST"); request.add_header("Content-Type", "application/json"); request.set_post_fields("{'a':'a','b':'b'}"); TransportCurl curl; curl.Perform(request, &response); - response.Wait(); EXPECT_EQ(200, response.status()); EXPECT_TRUE(response.header_completed()); EXPECT_TRUE(response.body_completed()); - EXPECT_STREQ(kServerVersion, response.GetHeader("Server")); + EXPECT_STREQ(test::TestHttpServer::kServerName(), + response.GetHeader("Server")); EXPECT_STREQ("{'a':'a','b':'b'}", response.GetBody()); + + test::RecordedRequest seen = server_->last_request(); + EXPECT_EQ("POST", seen.method); + EXPECT_EQ("/echo", seen.path); + EXPECT_EQ("application/json", seen.headers["content-type"]); + EXPECT_EQ("17", seen.headers["content-length"]); +} + +TEST_F(TransportCurlTest, HttpPostStreamedWithKnownSize) { + Request request; + request.set_url(Url("/echo").c_str()); + request.set_method("POST"); + request.set_post_fields("streamed body"); + request.options().stream_post_fields = true; + TestResponse response; + TransportCurl curl; + curl.Perform(request, &response); + EXPECT_EQ(200, response.status()); + EXPECT_STREQ("streamed body", response.GetBody()); + EXPECT_EQ("13", server_->last_request().headers["content-length"]); +} + +TEST_F(TransportCurlTest, HttpPostStreamedChunked) { + const std::string body = "0123456789abcdefghijklmnopqrstuvwxyz chunked!"; + ChunkedRequest request(body); + request.set_url(Url("/echo").c_str()); + request.set_method("POST"); + TestResponse response; + TransportCurl curl; + curl.Perform(request, &response); + EXPECT_EQ(200, response.status()); + EXPECT_EQ(body, response.GetBody()); + test::RecordedRequest seen = server_->last_request(); + EXPECT_EQ("chunked", seen.headers["transfer-encoding"]); + EXPECT_EQ(body, seen.body); +} + +TEST_F(TransportCurlTest, CustomMethodDoesNotLeakIntoNextRequest) { + TransportCurl curl; + { + Request request; + request.set_url(Url("/").c_str()); + request.set_method("PUT"); + TestResponse response; + curl.Perform(request, &response); + EXPECT_EQ(200, response.status()); + EXPECT_STREQ("PUT", response.GetBody()); + EXPECT_EQ("PUT", server_->last_request().method); + } + { + // Same TransportCurl: the custom method must not leak into subsequent + // requests. + Request request; + request.set_url(Url("/").c_str()); + TestResponse response; + curl.Perform(request, &response); + EXPECT_EQ(200, response.status()); + EXPECT_STREQ("test", response.GetBody()); + EXPECT_EQ("GET", server_->last_request().method); + } + { + // And a POST after a GET really posts. + Request request; + request.set_url(Url("/").c_str()); + request.set_method("POST"); + request.set_post_fields("again"); + TestResponse response; + curl.Perform(request, &response); + EXPECT_EQ(200, response.status()); + EXPECT_STREQ("again", response.GetBody()); + } +} + +TEST_F(TransportCurlTest, HttpStatusIsReported) { + Request request; + request.set_url(Url("/status/404").c_str()); + TestResponse response; + TransportCurl curl; + curl.Perform(request, &response); + EXPECT_EQ(404, response.status()); + EXPECT_EQ(1, response.completed_calls()); +} + +TEST_F(TransportCurlTest, HttpGetAsync) { + Request request; + request.set_url(Url("/slow?ms=200").c_str()); + TestResponse response; + TransportCurl curl; + curl.set_is_async(true); + auto start = std::chrono::steady_clock::now(); + curl.Perform(request, &response); + auto elapsed_ms = std::chrono::duration_cast( + std::chrono::steady_clock::now() - start) + .count(); + EXPECT_LT(elapsed_ms, 200); // Returned before the server replied. + ASSERT_TRUE(response.Wait()); + EXPECT_EQ(200, response.status()); + EXPECT_STREQ("test", response.GetBody()); + // Asynchronous completions are delivered off the shared I/O thread. + EXPECT_FALSE(response.completion_thread_was_io_thread()); +} + +TEST_F(TransportCurlTest, ConnectionRefusedCompletesWithoutStatus) { + // Port 1 (tcpmux) on loopback refuses connections immediately on every + // desktop platform. libcurl reports CURLE_COULDNT_CONNECT, which the + // transport surfaces as MarkCompleted() with status 0 (no HTTP response). + Request request; + request.set_url("http://127.0.0.1:1/"); + TestResponse response; + TransportCurl curl; + curl.Perform(request, &response); + EXPECT_EQ(0, response.status()); + EXPECT_EQ(1, response.completed_calls()); +} + +TEST_F(TransportCurlTest, ControllerReportsProgress) { + const size_t kSize = 2 * 1024 * 1024; + Request request; + request.set_url(Url("/large?size=2097152").c_str()); + TestResponse response; + TransportCurl curl; + flatbuffers::unique_ptr controller; + curl.Perform(request, &response, &controller); + ASSERT_NE(nullptr, controller.get()); + EXPECT_EQ(200, response.status()); + EXPECT_EQ(static_cast(kSize), controller->TransferSize()); + EXPECT_EQ(static_cast(kSize), controller->BytesTransferred()); + EXPECT_FLOAT_EQ(1.0f, controller->Progress()); + EXPECT_FALSE(controller->IsPaused()); + // The transfer is over: nothing left to pause or cancel. + EXPECT_FALSE(controller->Pause()); + EXPECT_FALSE(controller->Cancel()); +} + +TEST_F(TransportCurlTest, ControllerReportsUploadProgress) { + const std::string body(100000, 'u'); + Request request; + request.set_url(Url("/echo").c_str()); + request.set_method("POST"); + request.set_post_fields(body.c_str()); + TestResponse response; + TransportCurl curl; + flatbuffers::unique_ptr controller; + curl.Perform(request, &response, &controller); + EXPECT_EQ(200, response.status()); + EXPECT_EQ(static_cast(body.size()), controller->TransferSize()); + EXPECT_EQ(static_cast(body.size()), controller->BytesTransferred()); +} + +TEST_F(TransportCurlTest, ControllerPauseAndResume) { + Request request; + request.set_url(Url("/slow?ms=300").c_str()); + TestResponse response; + TransportCurl curl; + curl.set_is_async(true); + flatbuffers::unique_ptr controller; + curl.Perform(request, &response, &controller); + ASSERT_NE(nullptr, controller.get()); + + EXPECT_TRUE(controller->Pause()); + EXPECT_TRUE(controller->IsPaused()); + EXPECT_FALSE(controller->Pause()); // Already paused. + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + EXPECT_TRUE(controller->Resume()); + EXPECT_FALSE(controller->IsPaused()); + EXPECT_FALSE(controller->Resume()); // Not paused. + + ASSERT_TRUE(response.Wait()); + EXPECT_EQ(200, response.status()); + EXPECT_STREQ("test", response.GetBody()); +} + +TEST_F(TransportCurlTest, ControllerCancelInFlight) { + Request request; + request.set_url(Url("/slow?ms=5000").c_str()); + TestResponse response; + TransportCurl curl; + curl.set_is_async(true); + flatbuffers::unique_ptr controller; + curl.Perform(request, &response, &controller); + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + + auto start = std::chrono::steady_clock::now(); + EXPECT_TRUE(controller->Cancel()); + ASSERT_TRUE(response.Wait()); + auto elapsed_ms = std::chrono::duration_cast( + std::chrono::steady_clock::now() - start) + .count(); + EXPECT_LT(elapsed_ms, 4000); // Did not wait for the slow server. + EXPECT_EQ(util::HttpNoContent, response.status()); + EXPECT_EQ(0, response.completed_calls()); + EXPECT_EQ(1, response.failed_calls()); + EXPECT_FALSE(response.header_completed()); + EXPECT_FALSE(response.body_completed()); + EXPECT_FALSE(request.completed()); + + // A second cancel is a harmless no-op. + EXPECT_FALSE(controller->Cancel()); + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + EXPECT_EQ(1, response.failed_calls()); +} + +TEST_F(TransportCurlTest, ControllerCancelBeforeStart) { + Request request; + request.set_url(Url("/slow?ms=5000").c_str()); + TestResponse response; + TransportCurl curl; + curl.set_is_async(true); + flatbuffers::unique_ptr controller; + curl.Perform(request, &response, &controller); + // No delay: the I/O thread has most likely not even picked the transfer up. + EXPECT_TRUE(controller->Cancel()); + ASSERT_TRUE(response.Wait()); + EXPECT_EQ(util::HttpNoContent, response.status()); + EXPECT_EQ(1, response.failed_calls()); + EXPECT_FALSE(request.completed()); +} + +TEST_F(TransportCurlTest, ControllerDestroyedBeforeTransferCompletes) { + Request request; + request.set_url(Url("/slow?ms=300").c_str()); + TestResponse response; + TransportCurl curl; + curl.set_is_async(true); + { + flatbuffers::unique_ptr controller; + curl.Perform(request, &response, &controller); + // Dropping the controller must not cancel or crash the transfer. + } + ASSERT_TRUE(response.Wait()); + EXPECT_EQ(200, response.status()); +} + +TEST_F(TransportCurlTest, ControllerMoveTransfersControl) { + Request request; + request.set_url(Url("/slow?ms=5000").c_str()); + TestResponse response; + TransportCurl curl; + curl.set_is_async(true); + flatbuffers::unique_ptr controller; + curl.Perform(request, &response, &controller); + ASSERT_NE(nullptr, controller.get()); + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + + ControllerCurl moved( + std::move(*static_cast(controller.get()))); + // Moved-from controller becomes inert; moved-to controller controls transfer. + EXPECT_FALSE(controller->Pause()); + EXPECT_FALSE(controller->Cancel()); + EXPECT_TRUE(moved.Pause()); + EXPECT_TRUE(moved.IsPaused()); + EXPECT_TRUE(moved.Resume()); + EXPECT_FALSE(moved.IsPaused()); + EXPECT_TRUE(moved.Cancel()); + ASSERT_TRUE(response.Wait()); + EXPECT_EQ(util::HttpNoContent, response.status()); +} + +TEST_F(TransportCurlTest, ConcurrentAsyncRequestsOnOneTransport) { + const int kCount = 8; + TransportCurl curl; + curl.set_is_async(true); + std::vector> requests; + std::vector> responses; + auto start = std::chrono::steady_clock::now(); + for (int i = 0; i < kCount; ++i) { + requests.emplace_back(new Request()); + requests.back()->set_url(Url("/slow?ms=200").c_str()); + responses.emplace_back(new TestResponse()); + curl.Perform(*requests.back(), responses.back().get()); + } + for (int i = 0; i < kCount; ++i) { + ASSERT_TRUE(responses[i]->Wait()) << "transfer " << i; + EXPECT_EQ(200, responses[i]->status()) << "transfer " << i; + EXPECT_STREQ("test", responses[i]->GetBody()) << "transfer " << i; + } + auto elapsed_ms = std::chrono::duration_cast( + std::chrono::steady_clock::now() - start) + .count(); + EXPECT_LT(elapsed_ms, kCount * 200); +} + +TEST_F(TransportCurlTest, TimeoutFailsWithRequestTimeout) { + Request request; + request.set_url(Url("/slow?ms=5000").c_str()); + request.options().timeout_ms = 300; + TestResponse response; + TransportCurl curl; + auto start = std::chrono::steady_clock::now(); + curl.Perform(request, &response); + auto elapsed_ms = std::chrono::duration_cast( + std::chrono::steady_clock::now() - start) + .count(); + EXPECT_GE(elapsed_ms, 250); + EXPECT_LT(elapsed_ms, 4000); + EXPECT_EQ(util::HttpRequestTimeout, response.status()); + EXPECT_EQ(0, response.completed_calls()); + EXPECT_EQ(1, response.failed_calls()); + EXPECT_FALSE(request.completed()); +} + +TEST_F(TransportCurlTest, ManyConcurrentTransportsComplete) { + const int kCount = 16; + std::vector> transports; + std::vector> requests; + std::vector> responses; + auto start = std::chrono::steady_clock::now(); + for (int i = 0; i < kCount; ++i) { + transports.emplace_back(new TransportCurl()); + transports.back()->set_is_async(true); + requests.emplace_back(new Request()); + requests.back()->set_url(Url("/slow?ms=200").c_str()); + responses.emplace_back(new TestResponse()); + transports.back()->Perform(*requests.back(), responses.back().get()); + } + for (int i = 0; i < kCount; ++i) { + ASSERT_TRUE(responses[i]->Wait()) << "transfer " << i; + EXPECT_EQ(200, responses[i]->status()) << "transfer " << i; + EXPECT_STREQ("test", responses[i]->GetBody()) << "transfer " << i; + } + auto elapsed_ms = std::chrono::duration_cast( + std::chrono::steady_clock::now() - start) + .count(); + // Serialized they would take >= 16 * 200 ms. + EXPECT_LT(elapsed_ms, kCount * 200); +} + +TEST_F(TransportCurlTest, SequentialRequestsOnOneTransport) { + TransportCurl curl; + for (int i = 0; i < 5; ++i) { + Request request; + request.set_url(Url("/").c_str()); + TestResponse response; + curl.Perform(request, &response); + EXPECT_EQ(200, response.status()) << "request " << i; + EXPECT_STREQ("test", response.GetBody()) << "request " << i; + } +} + +TEST_F(TransportCurlTest, DestructorWaitsForInFlightRequest) { + Request request; + request.set_url(Url("/slow?ms=300").c_str()); + TestResponse response; + auto start = std::chrono::steady_clock::now(); + { + TransportCurl curl; + curl.set_is_async(true); + flatbuffers::unique_ptr controller; + curl.Perform(request, &response, &controller); + // ~Controller() runs first (reverse declaration order) while the transfer + // is still in flight, then ~TransportCurl() must block until it is done. + } + auto elapsed_ms = std::chrono::duration_cast( + std::chrono::steady_clock::now() - start) + .count(); + EXPECT_GE(elapsed_ms, 250); + ASSERT_TRUE(response.Wait()); + EXPECT_EQ(200, response.status()); + EXPECT_STREQ("test", response.GetBody()); +} + +// Tests below temporarily drop the fixture's only transport reference. If an +// assertion fails in between, the scope guard re-acquires it so that +// ~TransportCurl() / TearDown() still find a running loop instead of hanging +// or tripping CurlIoLoop::Release()'s refcount assert and taking the whole +// binary down. +class ScopedTransportReinit { + public: + explicit ScopedTransportReinit(bool cleanup_now = true) { + if (cleanup_now) CleanupTransportCurl(); + } + ~ScopedTransportReinit() { Reinit(); } + void Reinit() { + if (!reinitialized_) { + reinitialized_ = true; + InitTransportCurl(); + } + } + + private: + bool reinitialized_ = false; +}; + +TEST_F(TransportCurlTest, CleanupWithInFlightRequestFailsIt) { + Request request; + request.set_url(Url("/slow?ms=5000").c_str()); + TestResponse response; + TransportCurl curl; + curl.set_is_async(true); + curl.Perform(request, &response); + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + + // Drop the fixture's (only) reference: the loop shuts down and must fail the + // in-flight transfer before returning. + auto start = std::chrono::steady_clock::now(); + ScopedTransportReinit reinit; + auto elapsed_ms = std::chrono::duration_cast( + std::chrono::steady_clock::now() - start) + .count(); + EXPECT_LT(elapsed_ms, 4000); + EXPECT_FALSE(curl::CurlIoLoop::IsRunning()); + // CleanupTransportCurl() drains the completion thread before returning, so + // the response is already failed - no waiting needed. + EXPECT_TRUE(response.Wait(0)); + EXPECT_EQ(util::HttpNoContent, response.status()); + EXPECT_EQ(1, response.failed_calls()); +} + +TEST_F(TransportCurlTest, PerformAfterCleanupFailsGracefullyWithoutAsserting) { + Request in_flight_request; + in_flight_request.set_url(Url("/slow?ms=5000").c_str()); + TestResponse in_flight_response; + TransportCurl existing_transport; + existing_transport.set_is_async(true); + flatbuffers::unique_ptr in_flight_controller; + existing_transport.Perform(in_flight_request, &in_flight_response, + &in_flight_controller); + ASSERT_NE(nullptr, in_flight_controller.get()); + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + + // Shut down the shared I/O loop completely. + ScopedTransportReinit reinit; + EXPECT_FALSE(curl::CurlIoLoop::IsRunning()); + EXPECT_TRUE(in_flight_response.Wait(0)); + EXPECT_EQ(util::HttpNoContent, in_flight_response.status()); + + // Controller calls after CleanupTransportCurl() must not assert or UAF. + EXPECT_FALSE(in_flight_controller->Pause()); + EXPECT_FALSE(in_flight_controller->Resume()); + EXPECT_FALSE(in_flight_controller->Cancel()); + + // Constructing a TransportCurl and calling synchronous Perform() while no + // loop is running must fail immediately via AbortWithoutLoop(). + TransportCurl sync_transport; + Request sync_request; + sync_request.set_url(Url("/").c_str()); + TestResponse sync_response; + sync_transport.Perform(sync_request, &sync_response); + EXPECT_EQ(util::HttpNoContent, sync_response.status()); + EXPECT_EQ(0, sync_response.completed_calls()); + EXPECT_EQ(1, sync_response.failed_calls()); + + // Asynchronous Perform() + Controller operations while stopped must also fail + // cleanly without asserting. + TransportCurl async_transport; + async_transport.set_is_async(true); + Request async_request; + async_request.set_url(Url("/").c_str()); + TestResponse async_response; + flatbuffers::unique_ptr stopped_controller; + async_transport.Perform(async_request, &async_response, &stopped_controller); + EXPECT_TRUE(async_response.Wait()); + EXPECT_EQ(util::HttpNoContent, async_response.status()); + EXPECT_EQ(0, async_response.completed_calls()); + EXPECT_EQ(1, async_response.failed_calls()); + ASSERT_NE(nullptr, stopped_controller.get()); + EXPECT_FALSE(stopped_controller->Pause()); + EXPECT_FALSE(stopped_controller->Resume()); + EXPECT_FALSE(stopped_controller->Cancel()); + + // Re-initializing restores normal operation (and balances TearDown()). + reinit.Reinit(); + Request ok_request; + ok_request.set_url(Url("/").c_str()); + TestResponse ok_response; + sync_transport.Perform(ok_request, &ok_response); + EXPECT_EQ(200, ok_response.status()); + EXPECT_STREQ("test", ok_response.GetBody()); +} + +TEST_F(TransportCurlTest, PerformAndCancelRacingWithCleanupAndReinit) { + std::atomic stop{false}; + std::vector workers; + + // Async Perform() + Pause()/Resume()/Cancel() racers. + for (int t = 0; t < 2; ++t) { + workers.emplace_back([&stop]() { + TransportCurl transport; + transport.set_is_async(true); + while (!stop.load(std::memory_order_relaxed)) { + Request request; + request.set_url(Url("/slow?ms=150").c_str()); + TestResponse response; + flatbuffers::unique_ptr controller; + transport.Perform(request, &response, &controller); + if (controller) { + controller->Pause(); + controller->Resume(); + controller->Cancel(); + } + // EXPECT (not ASSERT): an early return from this lambda would destroy + // `transport` with the transfer in flight and hang in its destructor. + EXPECT_TRUE(response.Wait()); + EXPECT_EQ(1, response.completed_calls() + response.failed_calls()); + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + } + }); + } + + // Synchronous Perform() racer. + workers.emplace_back([&stop]() { + TransportCurl transport; + while (!stop.load(std::memory_order_relaxed)) { + Request request; + request.set_url(Url("/").c_str()); + TestResponse response; + transport.Perform(request, &response); + EXPECT_EQ(1, response.completed_calls() + response.failed_calls()); + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + } + }); + + // Cycle the global loop down and back up while workers are actively posting. + for (int cycle = 0; cycle < 15; ++cycle) { + std::this_thread::sleep_for(std::chrono::milliseconds(15)); + ScopedTransportReinit reinit; + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + } + + stop.store(true, std::memory_order_relaxed); + for (std::thread& worker : workers) { + worker.join(); + } + ASSERT_TRUE(curl::CurlIoLoop::IsRunning()); +} + +TEST_F(TransportCurlTest, PauseBeforeFirstByteHoldsTheTransfer) { + // Pause() immediately after Perform(): libcurl has no connection yet, so + // curl_easy_pause() alone would be a silent no-op and the transfer would + // complete while IsPaused() reports true. The data callbacks must hold the + // transfer at the first byte instead. + const size_t kSize = 256 * 1024; + Request request; + request.set_url(Url("/large?size=262144").c_str()); + TestResponse response; + TransportCurl curl; + curl.set_is_async(true); + flatbuffers::unique_ptr controller; + curl.Perform(request, &response, &controller); + ASSERT_NE(nullptr, controller.get()); + EXPECT_TRUE(controller->Pause()); + EXPECT_TRUE(controller->IsPaused()); + + // Plenty of time for a loopback transfer of 256 KiB to finish if the pause + // were ineffective. + EXPECT_FALSE(response.Wait(400)); + EXPECT_EQ(0, response.completed_calls()); + EXPECT_FALSE(response.body_completed()); + EXPECT_LT(controller->BytesTransferred(), static_cast(kSize)); + + EXPECT_TRUE(controller->Resume()); + ASSERT_TRUE(response.Wait()); + EXPECT_EQ(200, response.status()); + EXPECT_EQ(kSize, std::strlen(response.GetBody())); + EXPECT_EQ(static_cast(kSize), controller->BytesTransferred()); +} + +TEST_F(TransportCurlTest, AsyncCompletionRunsOffTheIoThreadAndMayBlock) { + // Asynchronous completions must not run on the shared I/O thread: product + // code (future callbacks, user listeners) is allowed to block there, e.g. on + // another request. Issue a synchronous request from inside MarkCompleted(). + class BlockingResponse : public TestResponse { + public: + explicit BlockingResponse(const std::string& url) : url_(url) {} + void MarkCompleted() override { + nested_was_io_thread_ = curl::CurlIoLoop::IsCurrentThreadIoThread(); + TransportCurl nested_transport; + Request nested_request; + nested_request.set_url(url_.c_str()); + TestResponse nested_response; + nested_transport.Perform(nested_request, &nested_response); + nested_status_ = nested_response.status(); + TestResponse::MarkCompleted(); + } + bool nested_was_io_thread() const { return nested_was_io_thread_; } + int nested_status() const { return nested_status_; } + + private: + std::string url_; + bool nested_was_io_thread_ = true; + int nested_status_ = 0; + }; + + Request request; + request.set_url(Url("/").c_str()); + BlockingResponse response(Url("/echo")); + TransportCurl curl; + curl.set_is_async(true); + curl.Perform(request, &response); + ASSERT_TRUE(response.Wait()); + EXPECT_EQ(200, response.status()); + EXPECT_FALSE(response.nested_was_io_thread()); + EXPECT_FALSE(response.completion_thread_was_io_thread()); + EXPECT_EQ(200, response.nested_status()); +} + +TEST_F(TransportCurlTest, FastRequestsNotDelayedByInFlightSlowRequest) { + // Keep one slow transfer parked in curl_multi_poll() so the old select() + + // 33 ms polling loop would stall new requests waiting for its poll cycle. + Request slow_request; + slow_request.set_url(Url("/slow?ms=2000").c_str()); + TestResponse slow_response; + TransportCurl slow_transport; + slow_transport.set_is_async(true); + slow_transport.Perform(slow_request, &slow_response); + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + + TransportCurl fast_transport; + auto start = std::chrono::steady_clock::now(); + for (int i = 0; i < 10; ++i) { + Request fast_request; + fast_request.set_url(Url("/").c_str()); + TestResponse fast_response; + fast_transport.Perform(fast_request, &fast_response); + EXPECT_EQ(200, fast_response.status()) << "request " << i; + EXPECT_STREQ("test", fast_response.GetBody()) << "request " << i; + } + auto elapsed_ms = std::chrono::duration_cast( + std::chrono::steady_clock::now() - start) + .count(); + // 10 sequential wakeups + loopback HTTP round trips complete in single-digit + // milliseconds via curl_multi_wakeup(). The legacy select() loop needed + // ~2000 ms here; 1500 ms still catches that regression with headroom for + // slow CI runners and sanitizer builds. + EXPECT_LT(elapsed_ms, 1500); + + ASSERT_TRUE(slow_response.Wait()); + EXPECT_EQ(200, slow_response.status()); +} + +#if !defined(_WIN32) +TEST_F(TransportCurlTest, HighFileDescriptorsAboveFdSetSizeDoNotStall) { + // Large desktop game engines (Unreal / Unity) commonly hold >1,024 open file + // descriptors (.pak archives, shader caches, audio banks). Under the legacy + // curl_multi_fdset() + select() loop, any socket with fd >= FD_SETSIZE (1024) + // was omitted from fd_set: if an earlier socket had fd < 1024, select() + // blocked completely blind to the high-FD transfers; otherwise maxfd == -1 + // degraded into 33 ms sleep ticks. Verify curl_multi_poll() handles sockets + // above FD_SETSIZE with zero stall even alongside a low-FD transfer. + struct rlimit orig_limit {}; + ASSERT_EQ(0, getrlimit(RLIMIT_NOFILE, &orig_limit)); + const rlim_t kNeeded = static_cast(FD_SETSIZE + 128); + bool raised_limit = false; + if (orig_limit.rlim_cur < kNeeded && orig_limit.rlim_max >= kNeeded) { + struct rlimit raised = orig_limit; + raised.rlim_cur = + orig_limit.rlim_max < static_cast(FD_SETSIZE + 256) + ? orig_limit.rlim_max + : static_cast(FD_SETSIZE + 256); + if (setrlimit(RLIMIT_NOFILE, &raised) == 0) { + raised_limit = true; + } + } + struct ScopedFdCleanup { + std::vector fds; + bool restore_rlimit = false; + struct rlimit saved {}; + ~ScopedFdCleanup() { + for (int fd : fds) close(fd); + if (restore_rlimit) setrlimit(RLIMIT_NOFILE, &saved); + } + } cleanup; + cleanup.restore_rlimit = raised_limit; + cleanup.saved = orig_limit; + + struct rlimit active_limit {}; + ASSERT_EQ(0, getrlimit(RLIMIT_NOFILE, &active_limit)); + if (active_limit.rlim_cur < kNeeded) { + GTEST_SKIP() << "RLIMIT_NOFILE (" << active_limit.rlim_cur + << ") is below FD_SETSIZE + 128 (" << kNeeded << ")"; + } + + // 1. Start a slow request first so it acquires a low FD (< FD_SETSIZE). + Request slow_request; + slow_request.set_url(Url("/slow?ms=2000").c_str()); + TestResponse slow_response; + TransportCurl slow_transport; + slow_transport.set_is_async(true); + slow_transport.Perform(slow_request, &slow_response); + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + + // 2. Reserve dummy file descriptors past FD_SETSIZE so subsequent sockets + // receive fd >= FD_SETSIZE + 32. + const int kTargetFd = FD_SETSIZE + 32; + cleanup.fds.reserve(kTargetFd + 16); + int max_opened_fd = -1; + while (max_opened_fd < kTargetFd) { + int fd = open("/dev/null", O_RDONLY); + if (fd < 0) break; + cleanup.fds.push_back(fd); + if (fd > max_opened_fd) max_opened_fd = fd; + } + ASSERT_GE(max_opened_fd, kTargetFd); + + // 3. Perform 8 fast requests whose sockets live above FD_SETSIZE. + auto start = std::chrono::steady_clock::now(); + for (int i = 0; i < 8; ++i) { + TransportCurl fast_transport; + Request fast_request; + fast_request.set_url(Url("/").c_str()); + TestResponse fast_response; + fast_transport.Perform(fast_request, &fast_response); + EXPECT_EQ(200, fast_response.status()) << "high-FD request " << i; + EXPECT_STREQ("test", fast_response.GetBody()) << "high-FD request " << i; + } + auto elapsed_ms = std::chrono::duration_cast( + std::chrono::steady_clock::now() - start) + .count(); + // ~2000 ms with the legacy select() loop (blind to fd >= FD_SETSIZE). + EXPECT_LT(elapsed_ms, 1500); + + ASSERT_TRUE(slow_response.Wait()); + EXPECT_EQ(200, slow_response.status()); } +#endif // !defined(_WIN32) +} // namespace } // namespace rest } // namespace firebase diff --git a/app/rest/transport_curl.cc b/app/rest/transport_curl.cc index 613bbaee28..81ed443aff 100644 --- a/app/rest/transport_curl.cc +++ b/app/rest/transport_curl.cc @@ -19,20 +19,24 @@ #include #include #include -#include +#include +#include +#include +#include #include "app/rest/controller_curl.h" +#include "app/rest/curl/curl_io_loop.h" +#include "app/rest/curl/tls_policy.h" #include "app/rest/util.h" #include "app/src/assert.h" #include "app/src/include/firebase/internal/mutex.h" #include "app/src/include/firebase/internal/platform.h" +#include "app/src/log.h" #include "app/src/semaphore.h" #include "app/src/thread.h" -#include "app/src/util.h" #include "curl/curl.h" #if FIREBASE_PLATFORM_WINDOWS -#include "Winsock2.h" #if defined(_MSC_VER) #pragma comment(lib, "advapi32.lib") #pragma comment(lib, "bcrypt.lib") @@ -41,400 +45,320 @@ #pragma comment(lib, "secur32.lib") #pragma comment(lib, "ws2_32.lib") #endif // defined(_MSC_VER) -#else -#include #endif // FIREBASE_PLATFORM_WINDOWS namespace firebase { namespace rest { -// An action requested to be performed in the background thread. -enum RequestedAction { - // Perform the transfer. - kRequestedActionPerform, - // Cancel an in-progress transfer. - kRequestedActionCancel, - // Pause an in-progress transfer. - kRequestedActionPause, - // Pause an in-progress transfer. - kRequestedActionResume, - // Quit the background thread. - kRequestedActionQuit, -}; +namespace { + +// True when the linked libcurl build includes HTTP/2 (nghttp2) support. +// Bundled SDK builds set USE_NGHTTP2=OFF to avoid an extra external dependency, +// while system-libcurl builds on Linux/macOS typically enable HTTP/2. +bool LibcurlSupportsHttp2() { + static const bool kSupported = []() { + const curl_version_info_data* info = curl_version_info(CURLVERSION_NOW); + return info != nullptr && (info->features & CURL_VERSION_HTTP2) != 0; + }(); + return kSupported; +} + +// A single FIFO worker thread on which asynchronous transfers deliver +// Response::MarkCompleted() / MarkFailed(). +// +// The curl I/O thread is shared by every REST transfer in the process, so +// product code (future completion, user callbacks, Storage file writes) must +// not run on it: a callback that blocks on another request would otherwise +// deadlock all networking. Completions are therefore handed to this queue. +// Unlike scheduler::Scheduler, Stop() runs every queued completion before +// joining, which is what lets CleanupTransportCurl() promise that in-flight +// transfers are failed before it returns. +class CompletionQueue { + public: + CompletionQueue() + : mutex_(Mutex::kModeNonRecursive), + wakeup_(0), + stopped_(false), + thread_(ThreadMain, this) {} -// A structure representing a pending TranportCurl::Perform call. -struct TransportCurlActionData { - explicit TransportCurlActionData() - : transport(nullptr), - action(kRequestedActionPerform), - curl(nullptr), - request(nullptr), - response(nullptr), - controller(nullptr) {} - // Transport that scheduled this request. - // Required by: - // * kRequestedActionPerform - // * kRequestedActionCancel - // * kRequestedActionPause - // * kRequestedActionResume - TransportCurl* transport; - // Action to perform on the curl transfer thread. - RequestedAction action; - // Pointer to the curl object to operate on. - // Required by: - // * kRequestedActionPerform - // * kRequestedActionCancel - // * kRequestedActionPause - // * kRequestedActionResume - CURL* curl; - // Data to send to the server as the request. - // Required by kRequestedActionPerform. - Request* request; - // Data received from the server as the response. - // Required by: - // * kRequestedActionPerform - // * kRequestedActionCancel - // * kRequestedActionPause - // * kRequestedActionResume - Response* response; - // Pointer to the controller. - // Optionally used by kRequestedActionPerform. - ControllerCurl* controller; - - // Create a quit action. - static TransportCurlActionData Quit() { - TransportCurlActionData transport_action; - transport_action.action = kRequestedActionQuit; - return transport_action; - } - - // Create a perform action. - static TransportCurlActionData Perform(TransportCurl* transport_curl, - Request* request, Response* response, - CURL* curl, - ControllerCurl* controller = nullptr) { - TransportCurlActionData transport_action; - transport_action.transport = transport_curl; - transport_action.action = kRequestedActionPerform; - transport_action.curl = curl; - transport_action.request = request; - transport_action.response = response; - transport_action.controller = controller; - return transport_action; - } - - // Create a cancellation action. - static TransportCurlActionData Cancel(TransportCurl* transport_curl, - Response* response, CURL* curl) { - return ResponseAction(transport_curl, kRequestedActionCancel, response, - curl); - } - - // Create a pause action. - static TransportCurlActionData Pause(TransportCurl* transport_curl, - Response* response, CURL* curl) { - return ResponseAction(transport_curl, kRequestedActionPause, response, - curl); - } - - // Create a resume action. - static TransportCurlActionData Resume(TransportCurl* transport_curl, - Response* response, CURL* curl) { - return ResponseAction(transport_curl, kRequestedActionResume, response, - curl); + ~CompletionQueue() { Stop(); } + + // Thread-safe. Returns false (without queuing) once Stop() has begun. + bool Post(std::function fn) { + { + MutexLock lock(mutex_); + if (stopped_) return false; + queue_.push_back(std::move(fn)); + } + wakeup_.Post(); + return true; + } + + // Runs everything queued so far, rejects further posts and joins the + // worker. Must not be called from the worker thread itself. + void Stop() { + { + MutexLock lock(mutex_); + if (stopped_) return; + stopped_ = true; + } + wakeup_.Post(); + thread_.Join(); } private: - // Create an action associated with a response. - static TransportCurlActionData ResponseAction(TransportCurl* transport_curl, - RequestedAction action, - Response* response, - CURL* curl) { - TransportCurlActionData transport_action; - transport_action.transport = transport_curl; - transport_action.action = action; - transport_action.curl = curl; - transport_action.response = response; - return transport_action; + static void ThreadMain(void* arg) { + static_cast(arg)->Run(); + } + + void Run() { + for (;;) { + std::deque> batch; + bool stop = false; + { + MutexLock lock(mutex_); + batch.swap(queue_); + stop = stopped_; + } + for (std::function& fn : batch) fn(); + if (stop) { + // Posts are rejected once stopped_ is set, so the queue is final. + MutexLock lock(mutex_); + if (queue_.empty()) return; + continue; + } + wakeup_.Wait(); + } } + + Mutex mutex_; + Semaphore wakeup_; + bool stopped_; + std::deque> queue_; + Thread thread_; }; -// The data needed to run a curl request in the background. When this class -// receives a curl handle it takes ownership and is responsible for running the -// request and deallocating the resources when the request is complete. -class BackgroundTransportCurl { - // Callback that completes a response. - typedef void (*CompleteFunction)(BackgroundTransportCurl* transport, - void* data); +// Reference-counted alongside CurlIoLoop by InitTransportCurl() / +// CleanupTransportCurl(). The mutex is leaked on purpose so it survives static +// destruction, matching the other process-wide mutexes in app/rest. +Mutex* g_completion_mutex = new Mutex(Mutex::kModeNonRecursive); +int g_completion_ref_count = 0; +CompletionQueue* g_completion_queue = nullptr; + +// Hands fn to the completion thread. Returns false if no queue exists (the +// transport is not initialized), in which case the caller runs fn inline. +bool PostCompletion(std::function fn) { + MutexLock lock(*g_completion_mutex); + return g_completion_queue != nullptr && + g_completion_queue->Post(std::move(fn)); +} + +} // namespace +// One HTTP request/response pair running on the shared CurlIoLoop. +// +// A RestTransfer is created by TransportCurl::PerformInternal() on the +// caller's thread (so that any Controller handed back to the caller is wired up +// before PerformInternal() returns), while its CURL easy handle is created, +// configured, driven, and destroyed exclusively on the I/O thread. Connection +// reuse, HTTP/2 stream multiplexing, TLS session caching, and DNS caching all +// live on CurlIoLoop's shared CURLM multi handle, so fresh per-transfer easy +// handles retain full pooling with zero cross-request option bleed. +class RestTransfer final : public curl::Transfer { public: - BackgroundTransportCurl(CURLM* curl_multi, CURL* curl, Request* request, - Response* response, Mutex* controller_mutex, - ControllerCurl* controller, - TransportCurl* transport_curl, - CompleteFunction complete, void* complete_data); - ~BackgroundTransportCurl(); - bool PerformBackground(Request* request); - - CURL* curl() const { return curl_; } - Response* response() const { return response_; } - void set_canceled(bool canceled) { canceled_ = canceled; } - void set_timed_out(bool timed_out) { timed_out_ = timed_out; } - ControllerCurl* controller() const { return controller_; } - TransportCurl* transport_curl() const { return transport_curl_; } + RestTransfer(TransportCurl* transport, Request* request, Response* response, + std::shared_ptr control); + ~RestTransfer() override; + + // I/O thread. Configures a fresh easy handle and adds it to the loop. If + // setup fails or the transfer was already canceled, fails immediately and + // destroys this object. + void Start(); + // I/O thread. Stops an in-flight transfer, fails the response with + // HttpNoContent and destroys this object. + void Cancel(); + // I/O thread. + void Pause(); + void Resume(); + // Any thread, only when the loop is no longer running (Post() failed): + // fails the response as canceled and destroys this object. + void AbortWithoutLoop(); + + // curl::Transfer: + CURL* easy() override { return easy_.get(); } + void OnDone(CURLcode result) override; private: + bool Setup(); void CheckOk(CURLcode code, const char* msg); - void CompleteOperation(); - - private: - CURLM* curl_multi_; - // The Curl handler - CURL* curl_; - // Error buffer - char err_buf_[CURL_ERROR_SIZE]; - // Curl error code - CURLcode err_code_; - // A linked list to set custom HTTP headers in the request. - curl_slist* request_header_; - // Request being performed. + // Builds the completion step (MarkCompleted / MarkFailed on request_ and + // response_) for the current canceled_/timed_out_ state. The returned + // function does not touch this RestTransfer. + std::function MakeCompletion() const; + + static size_t HeaderCallback(char* buffer, size_t size, size_t nitems, + void* userdata); + static size_t WriteCallback(char* buffer, size_t size, size_t nmemb, + void* userdata); + static size_t ReadCallback(char* buffer, size_t size, size_t nitems, + void* userdata); + static int ProgressCallback(void* userdata, curl_off_t dltotal, + curl_off_t dlnow, curl_off_t ultotal, + curl_off_t ulnow); + + TransportCurl* transport_; Request* request_; - // The response that needs to be completed. Response* response_; - // Guards controller_. - Mutex* controller_mutex_; - // Controller associated with the transfer. - ControllerCurl* controller_; - TransportCurl* transport_curl_; - // If the transport was synchronous, block using this semaphore. - // TODO(b/67899466): Remove this once we transition to exclusively - // asynchronous curl requests. - Semaphore* semaphore_; - CompleteFunction complete_; - void* complete_data_; - // Whether the operation has been canceled. + std::shared_ptr control_; + curl::UniqueSList headers_; + char err_buf_[CURL_ERROR_SIZE]; + // Declared after headers_ and err_buf_ so any implicit destruction also tears + // down easy_ before the buffers it references. + curl::UniqueEasy easy_; + // First error reported during setup. + CURLcode err_code_; bool canceled_; - // Whether the operation timed out. bool timed_out_; + // Set when Request::ReadBody() asked to abort the upload, so that the + // resulting CURLE_ABORTED_BY_CALLBACK is not mistaken for a cancellation. + bool read_aborted_; + // Sticky pause flag (I/O thread only). curl_easy_pause() only takes effect + // once libcurl has a connection for the handle; before that (DNS, TCP, TLS, + // or before Start() ran) it returns CURLE_BAD_FUNCTION_ARGUMENT and records + // nothing. The data callbacks consult this flag and return + // CURL_*FUNC_PAUSE so a Pause() issued at any point really stops the + // transfer at the first byte. + bool paused_; }; -// The data common to both threads. This is used to communicate when the -// background thread should shut down, and when new requests have come in. -class CurlThread { - public: - CurlThread(); - // Shut down the request processing thread. - ~CurlThread(); - - // Schedule an action on the ProcessRequests thread. - void ScheduleAction(const TransportCurlActionData& action_data); - - // Cancel a request or flush scheduled matching requests. - int CancelRequest(TransportCurl* transport_curl, Response* response, - CURL* curl); - - private: - // Pull the next request from the queue, optionally blocking if the queue - // semaphore has no remaining grants. Returns true if an action was returned, - // false otherwise. - bool GetNextAction(TransportCurlActionData* data, - int64_t wait_for_milliseconds); - - // Add a request to the set of running requests. - void AddTransfer(BackgroundTransportCurl* transport); - // Remove a response from the set of running responses. - // Returns a transport if the response is found in the set of running - // requests. - BackgroundTransportCurl* RemoveTransfer(Response* response); - - // Cancel all outstanding requests. - void CancelAllTransfers(); - - Mutex* mutex() { return &mutex_; } - - // Process requests from action_data_ the see the function definition for the - // complete documentation. - void ProcessRequests(); - - // Thread entry point that calls ProcessRequests. - static void ProcessRequests(void* thread); - - private: - flatbuffers::unique_ptr background_thread_; - // Guards mutation of action_data_queue_, responses_ and - // controller_ pointers in BackgroundTransportCurl instances. - Mutex mutex_; - // When signalled the thread will pull the next item from action_data. - // Should be signalled for each item added to the action_data queue. - // This is also signalled - Semaphore action_data_signal_; - std::deque action_data_queue_; - // Transports for in progress requests for each response. This allows all - // requests to be canceled when this object is cleaned up. - std::map transport_by_response_; - // Polling interval while requests are in progress. - static const int64_t kPollIntervalMilliseconds; -}; - -namespace { - -// Called when curl has received the header from the server. -size_t CurlHeaderCallback(char* buffer, size_t size, size_t nitems, - void* userdata) { - FIREBASE_ASSERT_RETURN(0, userdata != nullptr); - Response* response = static_cast(userdata); - // Size is always 1, see https://curl.haxx.se/mail/lib-2010-12/0123.html. - if (response->ProcessHeader(buffer, size * nitems)) { - return size * nitems; - } else { - return 0; - } +void TransferControl::PauseOnIoThread(RestTransfer* transfer) { + transfer->Pause(); } -// Called when curl has received more data from the server. -size_t CurlWriteCallback(char* buffer, size_t size, size_t nmemb, - void* userdata) { - FIREBASE_ASSERT_RETURN(0, userdata != nullptr); - Response* response = static_cast(userdata); - // Size is always 1, see https://curl.haxx.se/mail/lib-2010-12/0123.html. - if (response->ProcessBody(buffer, size * nmemb)) { - return size * nmemb; - } else { - return 0; - } +void TransferControl::ResumeOnIoThread(RestTransfer* transfer) { + transfer->Resume(); } -// Called when curl is ready to send more data to the server. -size_t CurlReadCallback(char* buffer, size_t size, size_t nitems, - void* userdata) { - FIREBASE_ASSERT_RETURN(0, userdata != nullptr); - Request* request = reinterpret_cast(userdata); - bool abort; - size_t data_read = request->ReadBody(buffer, size * nitems, &abort); - return abort ? CURL_READFUNC_ABORT : data_read; +void TransferControl::CancelOnIoThread(RestTransfer* transfer) { + transfer->Cancel(); } -// Data accessible by both threads. -CurlThread* g_curl_thread = nullptr; - -// Count initializations that multiple libraries can use this simultaneously. -int g_initialize_count = 0; - -// Mutex for Curl initialization. -Mutex* g_initialize_mutex = new Mutex(); - -} // namespace - void InitTransportCurl() { - MutexLock lock(*g_initialize_mutex); - if (g_initialize_count == 0) { - // Initialize curl. - CURLcode global_init_code = curl_global_init(CURL_GLOBAL_ALL); - FIREBASE_ASSERT_MESSAGE(global_init_code == CURLE_OK, - "curl global init failed with code %d", - global_init_code); - - // Kick off background thread. - assert(!g_curl_thread); - g_curl_thread = new CurlThread(); - } - g_initialize_count++; + curl::CurlIoLoop::Acquire(); + MutexLock lock(*g_completion_mutex); + if (g_completion_ref_count++ == 0) { + FIREBASE_ASSERT(g_completion_queue == nullptr); + g_completion_queue = new CompletionQueue(); + } } void CleanupTransportCurl() { - MutexLock lock(*g_initialize_mutex); - assert(g_initialize_count > 0); - g_initialize_count--; - if (g_initialize_count == 0) { - // Shut down background thread. - delete g_curl_thread; - g_curl_thread = nullptr; - - // Clean up curl. - curl_global_cleanup(); + // Stopping the loop fails every in-flight transfer; their completions are + // queued on the completion thread, which is drained below. + curl::CurlIoLoop::Release(); + CompletionQueue* queue = nullptr; + { + MutexLock lock(*g_completion_mutex); + FIREBASE_ASSERT_MESSAGE( + g_completion_ref_count > 0, + "CleanupTransportCurl() without matching InitTransportCurl()"); + if (--g_completion_ref_count == 0) { + queue = g_completion_queue; + g_completion_queue = nullptr; + } } + // Drained outside the lock: a completion callback is allowed to call + // InitTransportCurl() / CleanupTransportCurl() itself. + delete queue; } -BackgroundTransportCurl::BackgroundTransportCurl( - CURLM* curl_multi, CURL* curl, Request* request, Response* response, - Mutex* controller_mutex, ControllerCurl* controller, - TransportCurl* transport_curl, CompleteFunction complete, - void* complete_data) - : curl_multi_(curl_multi), - curl_(curl), - err_code_(CURLE_OK), - request_header_(nullptr), +// --------------------------------------------------------------------------- +// RestTransfer +// --------------------------------------------------------------------------- + +RestTransfer::RestTransfer(TransportCurl* transport, Request* request, + Response* response, + std::shared_ptr control) + : transport_(transport), request_(request), response_(response), - controller_mutex_(controller_mutex), - controller_(controller), - transport_curl_(transport_curl), - complete_(complete), - complete_data_(complete_data), + control_(std::move(control)), + headers_(nullptr), + easy_(nullptr), + err_code_(CURLE_OK), canceled_(false), - timed_out_(false) { - assert(curl_multi_); - assert(curl_); - assert(transport_curl); - FIREBASE_ASSERT_MESSAGE(curl_ != nullptr, - "failed to start a curl easy session"); - // Set the error buffer before all other curl_easy_setopt since CheckOk() - // depends on it. - err_buf_[0] = '\0'; // Make sure it is empty at beginning. - if (controller_) { - controller_->InitializeControllerHandle(&controller_, controller_mutex); + timed_out_(false), + read_aborted_(false), + paused_(false) { + assert(transport_); + assert(request_); + assert(response_); + err_buf_[0] = '\0'; + if (control_) { + MutexLock lock(control_->mutex); + control_->transfer = this; } } -BackgroundTransportCurl::~BackgroundTransportCurl() { - // SignalTransfer complete can tear down the TransportCurl referenced by this - // object, we need to remove all references to objects that could be destroyed - // by TransportCurl. - { - MutexLock lock(*controller_mutex_); - if (controller_) { - controller_->InitializeControllerHandle(nullptr, nullptr); - controller_->set_transferring(false); - } - } - curl_multi_remove_handle(curl_multi_, curl_); - if (request_header_) { - curl_slist_free_all(request_header_); - request_header_ = nullptr; - } - - // If this is an asynchronous operation, MarkFailed() or MarkCompleted() - // could end up attempting to tear down TransportCurl so we signal - // completion here. - if (transport_curl_->is_async()) { - transport_curl_->SignalTransferComplete(); - CompleteOperation(); +RestTransfer::~RestTransfer() { + if (control_) { + MutexLock lock(control_->mutex); + control_->transfer = nullptr; + control_->transferring = false; + control_->paused = false; + } + + // Destroy the easy handle (already detached from CURLM) on the I/O thread + // before freeing headers_ or signaling TransportCurl. + easy_.reset(); + headers_.reset(); + + TransportCurl* transport = transport_; + transport_ = nullptr; + std::function completion = MakeCompletion(); + if (transport->is_async()) { + // If this is an asynchronous operation, MarkFailed() or MarkCompleted() + // could end up attempting to tear down TransportCurl so we signal + // completion here. Nothing below may touch `transport`. + transport->SignalTransferComplete(); + // Deliver off the I/O thread (see CompletionQueue). Falls back to inline + // delivery only when the transport is not initialized at all, i.e. the + // caller never called InitTransportCurl(). + if (!PostCompletion(completion)) completion(); } else { // Synchronous operations need all data present in the response before // Perform() returns so signal complete after MarkFailed() or - // MarkCompleted(). - CompleteOperation(); - transport_curl_->SignalTransferComplete(); + // MarkCompleted(). The caller is blocked in Perform(), so running the + // completion here (on the I/O thread) cannot run arbitrary user code. + completion(); + transport->SignalTransferComplete(); } } -void BackgroundTransportCurl::CompleteOperation() { - if (complete_) complete_(this, complete_data_); +std::function RestTransfer::MakeCompletion() const { + Request* request = request_; + Response* response = response_; if (canceled_) { - response_->set_status(rest::util::HttpNoContent); - request_->MarkFailed(); - response_->MarkFailed(); - } else if (timed_out_) { - response_->set_status(rest::util::HttpRequestTimeout); - request_->MarkFailed(); - response_->MarkFailed(); - } else { - request_->MarkCompleted(); - response_->MarkCompleted(); - } + return [request, response]() { + response->set_status(rest::util::HttpNoContent); + request->MarkFailed(); + response->MarkFailed(); + }; + } + if (timed_out_) { + return [request, response]() { + response->set_status(rest::util::HttpRequestTimeout); + request->MarkFailed(); + response->MarkFailed(); + }; + } + return [request, response]() { + request->MarkCompleted(); + response->MarkCompleted(); + }; } -void BackgroundTransportCurl::CheckOk(CURLcode code, const char* msg) { +void RestTransfer::CheckOk(CURLcode code, const char* msg) { if (code == CURLE_OK) { return; } @@ -447,48 +371,147 @@ void BackgroundTransportCurl::CheckOk(CURLcode code, const char* msg) { } } -bool BackgroundTransportCurl::PerformBackground(Request* request) { - RequestOptions& options = request->options(); - CheckOk(curl_easy_setopt(curl_, CURLOPT_ERRORBUFFER, err_buf_), +size_t RestTransfer::HeaderCallback(char* buffer, size_t size, size_t nitems, + void* userdata) { + FIREBASE_ASSERT_RETURN(0, userdata != nullptr); + RestTransfer* transfer = static_cast(userdata); + // libcurl buffers the data and redelivers it after curl_easy_pause(CONT). + if (transfer->paused_) return CURL_WRITEFUNC_PAUSE; + // Size is always 1, see https://curl.haxx.se/mail/lib-2010-12/0123.html. + if (transfer->response_->ProcessHeader(buffer, size * nitems)) { + return size * nitems; + } else { + return 0; + } +} + +size_t RestTransfer::WriteCallback(char* buffer, size_t size, size_t nmemb, + void* userdata) { + FIREBASE_ASSERT_RETURN(0, userdata != nullptr); + RestTransfer* transfer = static_cast(userdata); + if (transfer->paused_) return CURL_WRITEFUNC_PAUSE; + // Size is always 1, see https://curl.haxx.se/mail/lib-2010-12/0123.html. + if (transfer->response_->ProcessBody(buffer, size * nmemb)) { + return size * nmemb; + } else { + return 0; + } +} + +size_t RestTransfer::ReadCallback(char* buffer, size_t size, size_t nitems, + void* userdata) { + FIREBASE_ASSERT_RETURN(0, userdata != nullptr); + RestTransfer* transfer = static_cast(userdata); + if (transfer->paused_) return CURL_READFUNC_PAUSE; + bool abort = false; + size_t data_read = + transfer->request_->ReadBody(buffer, size * nitems, &abort); + if (abort) { + transfer->read_aborted_ = true; + return CURL_READFUNC_ABORT; + } + return data_read; +} + +int RestTransfer::ProgressCallback(void* userdata, curl_off_t dltotal, + curl_off_t dlnow, curl_off_t ultotal, + curl_off_t ulnow) { + RestTransfer* transfer = static_cast(userdata); + if (transfer == nullptr || !transfer->control_) return 0; + switch (transfer->control_->direction) { + case kTransferDirectionUpload: + transfer->control_->SetTransferSize(static_cast(ultotal)); + transfer->control_->SetBytesTransferred(static_cast(ulnow)); + break; + case kTransferDirectionDownload: + transfer->control_->SetTransferSize(static_cast(dltotal)); + transfer->control_->SetBytesTransferred(static_cast(dlnow)); + break; + } + return 0; // Continue the transfer. +} + +bool RestTransfer::Setup() { + easy_.reset(curl_easy_init()); + if (easy_ == nullptr) { + LogError("failed to initialize curl easy handle"); + err_code_ = CURLE_FAILED_INIT; + return false; + } + CURL* easy = easy_.get(); + RequestOptions& options = request_->options(); + + // Set the error buffer before all other options since CheckOk() depends on + // it. + err_buf_[0] = '\0'; + CheckOk(curl_easy_setopt(easy, CURLOPT_ERRORBUFFER, err_buf_), "set error buffer"); // Ensure that we are only accepting HTTP(S) traffic. // TODO(73540740): Remove support for HTTP. - CheckOk(curl_easy_setopt(curl_, CURLOPT_PROTOCOLS_STR, "http,https"), + CheckOk(curl_easy_setopt(easy, CURLOPT_PROTOCOLS_STR, "http,https"), "set valid protocols"); - // Verify SSL. - CheckOk(curl_easy_setopt(curl_, CURLOPT_SSL_VERIFYPEER, 1L), "verify peer"); - CheckOk(curl_easy_setopt(curl_, CURLOPT_SSL_VERIFYHOST, 2L), "verify host"); + // libcurl is driven from a dedicated thread: never let it install signal + // handlers (SIGPIPE save/ignore/restore around every curl_multi_perform(), + // SIGALRM for name resolution), which race with handlers the application + // installs on other threads. + CheckOk(curl_easy_setopt(easy, CURLOPT_NOSIGNAL, 1L), "disable curl signals"); -#ifdef FIREBASE_SSL_CAPATH - CheckOk(curl_easy_setopt(curl_, CURLOPT_CAPATH, FIREBASE_SSL_CAPATH), - "CA Path"); -#endif + // Peer/hostname verification, TLS 1.2 minimum, and OS trust store selection + // are centralized in app/rest/curl/tls_policy.h. + CheckOk(curl::ApplyTlsPolicy(easy), "apply TLS policy"); // Set callback functions. - CheckOk(curl_easy_setopt(curl_, CURLOPT_HEADERFUNCTION, CurlHeaderCallback), + CheckOk(curl_easy_setopt(easy, CURLOPT_HEADERFUNCTION, &HeaderCallback), "set http header callback"); - CheckOk(curl_easy_setopt(curl_, CURLOPT_HEADERDATA, response_), + CheckOk(curl_easy_setopt(easy, CURLOPT_HEADERDATA, static_cast(this)), "set http header callback data"); - CheckOk(curl_easy_setopt(curl_, CURLOPT_WRITEFUNCTION, CurlWriteCallback), + CheckOk(curl_easy_setopt(easy, CURLOPT_WRITEFUNCTION, &WriteCallback), "set http body write callback"); - CheckOk(curl_easy_setopt(curl_, CURLOPT_WRITEDATA, response_), + CheckOk(curl_easy_setopt(easy, CURLOPT_WRITEDATA, static_cast(this)), "set http body write callback data"); - CheckOk(curl_easy_setopt(curl_, CURLOPT_READFUNCTION, CurlReadCallback), + CheckOk(curl_easy_setopt(easy, CURLOPT_READFUNCTION, &ReadCallback), "set http body read callback"); - CheckOk(curl_easy_setopt(curl_, CURLOPT_READDATA, request), + CheckOk(curl_easy_setopt(easy, CURLOPT_READDATA, static_cast(this)), "set http body read callback data"); - CheckOk(curl_easy_setopt(curl_, CURLOPT_TIMEOUT_MS, options.timeout_ms), + CheckOk(curl_easy_setopt(easy, CURLOPT_TIMEOUT_MS, + static_cast(options.timeout_ms)), // NOLINT "set http timeout milliseconds"); - // Force HTTP/1.1 even when linked against a libcurl build that defaults to - // HTTP/2 (e.g., system libcurl or internal builds with nghttp2 enabled): - // Response::ProcessHeader() parses status lines with "HTTP/%*d.%*d %d", which - // does not match HTTP/2's "HTTP/2 200" status line, and streaming POST - // uploads rely on HTTP/1.1 "Transfer-Encoding: chunked". - CheckOk(curl_easy_setopt(curl_, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1), - "set http version to http1"); + // Progress reporting for ControllerCurl. libcurl invokes the callback from + // within curl_multi_perform() on the I/O thread. + if (control_) { + CheckOk(curl_easy_setopt(easy, CURLOPT_NOPROGRESS, 0L), "enable progress"); + CheckOk(curl_easy_setopt(easy, CURLOPT_XFERINFOFUNCTION, &ProgressCallback), + "set progress callback"); + CheckOk( + curl_easy_setopt(easy, CURLOPT_XFERINFODATA, static_cast(this)), + "set progress callback data"); + } + + std::string method = util::ToUpper(options.method); + const bool stream_unknown_post_size = + (method == util::kPost && options.stream_post_fields && + request_->GetPostFieldsSize() == ~static_cast(0)); + bool has_transfer_encoding_header = stream_unknown_post_size; + for (const auto& pair : options.header) { + if (util::ToLower(pair.first) == "transfer-encoding") { + has_transfer_encoding_header = true; + break; + } + } + + // Negotiate HTTP/2 over TLS (ALPN) when libcurl is built with HTTP/2 support, + // unless the request relies on an HTTP/1.1 Transfer-Encoding header + // (forbidden by RFC 9113 section 8.2.2). Plain HTTP and servers without h2 + // fall back to HTTP/1.1 automatically. + const long http_version = // NOLINT + (!has_transfer_encoding_header && LibcurlSupportsHttp2()) + ? static_cast(CURL_HTTP_VERSION_2TLS) // NOLINT + : static_cast(CURL_HTTP_VERSION_1_1); // NOLINT + CheckOk(curl_easy_setopt(easy, CURLOPT_HTTP_VERSION, http_version), + "set http version"); // SDK error in initialization stage is not recoverable. FIREBASE_ASSERT(err_code_ == CURLE_OK); @@ -497,64 +520,61 @@ bool BackgroundTransportCurl::PerformBackground(Request* request) { // Set VERBOSE for debug; does not affect the HTTP connection. if (options.verbose) { - curl_easy_setopt(curl_, CURLOPT_VERBOSE, 1L); - } - - if (request_header_) { - curl_slist_free_all(request_header_); - request_header_ = nullptr; + curl_easy_setopt(easy, CURLOPT_VERBOSE, 1L); } + headers_.reset(); + auto append_header = [this](const char* header) { + curl_slist* next = curl_slist_append(headers_.get(), header); + if (next != nullptr) { + headers_.release(); + headers_.reset(next); + } + }; for (const auto& pair : options.header) { std::string header; header.reserve(pair.first.size() + pair.second.size() + 1); header.append(pair.first); header.append(1, util::kHttpHeaderSeparator); header.append(pair.second); - request_header_ = curl_slist_append(request_header_, header.c_str()); + append_header(header.c_str()); } - std::string method = util::ToUpper(options.method); - if (method == util::kPost && request_->options().stream_post_fields) { + if (method == util::kPost && options.stream_post_fields) { size_t transfer_size = request_->GetPostFieldsSize(); // If the upload size is unknown use chunked encoding. - if (transfer_size == ~static_cast(0)) { + if (stream_unknown_post_size) { // To use POST to a HTTP 1.1 sever we need to use chunked encoding. // This is not supported by HTTP 1.0 servers which need to know the size // of the request up front. - request_header_ = - curl_slist_append(request_header_, "Transfer-Encoding: chunked"); + append_header("Transfer-Encoding: chunked"); } else { // Set the expected POST size, to be compatible with HTTP 1.0 servers. - curl_easy_setopt(curl_, CURLOPT_POSTFIELDSIZE, - static_cast(transfer_size)); // NOLINT - if (controller_) { - controller_->set_transfer_size(static_cast(transfer_size)); + curl_easy_setopt(easy, CURLOPT_POSTFIELDSIZE_LARGE, + static_cast(transfer_size)); + if (control_) { + control_->SetTransferSize(static_cast(transfer_size)); } } } - if (request_header_ != nullptr) { - CheckOk(curl_easy_setopt(curl_, CURLOPT_HTTPHEADER, request_header_), + if (headers_ != nullptr) { + CheckOk(curl_easy_setopt(easy, CURLOPT_HTTPHEADER, headers_.get()), "set http header"); } - // Set PRIVATE pointer, so that we can get the response when the request - // completes. - CheckOk(curl_easy_setopt(curl_, CURLOPT_PRIVATE, this), - "set private pointer"); // Set URL - CheckOk(curl_easy_setopt(curl_, CURLOPT_URL, options.url.c_str()), + CheckOk(curl_easy_setopt(easy, CURLOPT_URL, options.url.c_str()), "set http url"); // Set the method. if (method == util::kGet) { - CheckOk(curl_easy_setopt(curl_, CURLOPT_HTTPGET, 1L), "set http method"); + CheckOk(curl_easy_setopt(easy, CURLOPT_HTTPGET, 1L), "set http method"); } else if (method == util::kPost) { - CheckOk(curl_easy_setopt(curl_, CURLOPT_POST, 1L), "set http method"); + CheckOk(curl_easy_setopt(easy, CURLOPT_POST, 1L), "set http method"); } else { // For all other method. Note: If your code is depending on a documented // method e.g. PUT, try add another else-if branch above instead of relying // on this generic mechanism. - CheckOk(curl_easy_setopt(curl_, CURLOPT_CUSTOMREQUEST, method.c_str()), + CheckOk(curl_easy_setopt(easy, CURLOPT_CUSTOMREQUEST, method.c_str()), "set http method"); } @@ -570,411 +590,164 @@ bool BackgroundTransportCurl::PerformBackground(Request* request) { request_->options().post_fields = post_fields_temp; // Specify the size to make sure the whole post_fields is consumed. - CheckOk(curl_easy_setopt(curl_, CURLOPT_POSTFIELDSIZE, - options.post_fields.size()), - "set http post fields"); - CheckOk(curl_easy_setopt(curl_, CURLOPT_POSTFIELDS, - options.post_fields.c_str()), - "set http post fields"); - if (controller_) { - controller_->set_transfer_size( + // CURLOPT_POSTFIELDS does not copy: options.post_fields (owned by the + // Request, which outlives the transfer) must stay unchanged until done. + CheckOk( + curl_easy_setopt(easy, CURLOPT_POSTFIELDSIZE_LARGE, + static_cast(options.post_fields.size())), + "set http post fields"); + CheckOk( + curl_easy_setopt(easy, CURLOPT_POSTFIELDS, options.post_fields.c_str()), + "set http post fields"); + if (control_) { + control_->SetTransferSize( static_cast(options.post_fields.size())); } } - if (err_code_ == CURLE_OK) { - // Add the easy handle to the multi handle to prepare operation. - return curl_multi_add_handle(curl_multi_, curl_) == CURLM_OK; - } else { - // If any error happens during the setup, we do not perform http request and - // return immediately instead. - response_->set_sdk_error_code(err_code_); - set_canceled(true); - return false; - } -} - -TransportCurl::TransportCurl() - : is_async_(false), running_transfers_(0), running_transfers_semaphore_(0) { - curl_ = util::CreateCurlPtr(); - assert(curl_ != nullptr); // Failed to get curl pointer. Something is wrong. -} - -TransportCurl::~TransportCurl() { - WaitForAllTransfersToComplete(); - util::DestroyCurlPtr(curl_); -} - -// Default polling interval while requests are in progress. -const int64_t CurlThread::kPollIntervalMilliseconds = 33; // ~30Hz - -CurlThread::CurlThread() : action_data_signal_(0) { - // Normally we would use make_new() here, but this is not a std::unique_ptr - // and make_new() isn't supported by all targets we build for - // NOLINTNEXTLINE - background_thread_.reset(new Thread(ProcessRequests, this)); // NOLINT -} - -CurlThread::~CurlThread() { - CancelAllTransfers(); - ScheduleAction(TransportCurlActionData::Quit()); - background_thread_->Join(); + return err_code_ == CURLE_OK; } -void CurlThread::ScheduleAction(const TransportCurlActionData& action_data) { - MutexLock lock(mutex_); - action_data_queue_.push_back(action_data); - action_data_signal_.Post(); -} - -int CurlThread::CancelRequest(TransportCurl* transport_curl, Response* response, - CURL* curl) { - int removed_from_queue = 0; - MutexLock lock(mutex_); - // Remove any actions that are queued for this transfer before scheduling - // the cancellation. - auto it = action_data_queue_.begin(); - while (it != action_data_queue_.end()) { - if (!(it->transport == transport_curl && it->response == response && - it->curl == curl)) { - it++; - continue; - } - switch (it->action) { - case kRequestedActionPerform: - removed_from_queue++; - FIREBASE_CASE_FALLTHROUGH; - case kRequestedActionPause: - FIREBASE_CASE_FALLTHROUGH; - case kRequestedActionCancel: - FIREBASE_CASE_FALLTHROUGH; - case kRequestedActionResume: - action_data_queue_.erase(it); - it = action_data_queue_.begin(); - break; - default: - it++; - break; +void RestTransfer::Start() { + if (control_) { + bool already_canceled = false; + { + MutexLock lock(control_->mutex); + already_canceled = control_->canceled; + // A Pause() issued before the I/O thread picked the transfer up: the + // posted PauseOnIoThread() runs after us, but the data callbacks honor + // paused_ from the very first byte either way. + paused_ = control_->paused; } - } - // Make sure the transfer is currently running. - bool transferring = false; - for (auto it = transport_by_response_.begin(); - it != transport_by_response_.end(); ++it) { - BackgroundTransportCurl* transport = it->second; - if (it->first == response && - transport->transport_curl() == transport_curl && - transport->curl() == curl) { - transferring = true; + if (already_canceled) { + canceled_ = true; + delete this; + return; } } - if (transferring) { - ScheduleAction( - TransportCurlActionData::Cancel(transport_curl, response, curl)); - } - return removed_from_queue; + if (Setup()) { + CURLMcode code = curl::CurlIoLoop::Get().Add(*this); + if (code == CURLM_OK) return; // OnDone() will finish the transfer. + LogError("failed to start transfer to %s: %s", + request_->options().url.c_str(), curl_multi_strerror(code)); + err_code_ = CURLE_FAILED_INIT; + } + // If any error happens during the setup, we do not perform the http request + // and fail the response immediately instead. + response_->set_sdk_error_code(err_code_); + canceled_ = true; + delete this; } -bool CurlThread::GetNextAction(TransportCurlActionData* data, - int64_t wait_for_milliseconds) { - if (wait_for_milliseconds) { - if (wait_for_milliseconds > 0) { - action_data_signal_.TimedWait(wait_for_milliseconds); - } else { - action_data_signal_.Wait(); - } - } else { - action_data_signal_.TryWait(); - } - MutexLock lock(mutex_); - if (action_data_queue_.empty()) { - return false; - } - assert(data); - *data = action_data_queue_.front(); - action_data_queue_.pop_front(); - return true; +void RestTransfer::Cancel() { + curl::CurlIoLoop::Get().Remove(*this); + canceled_ = true; + delete this; } -void CurlThread::AddTransfer(BackgroundTransportCurl* transport) { - MutexLock lock(mutex_); - assert(transport->response()); - transport_by_response_[transport->response()] = transport; +void RestTransfer::Pause() { + paused_ = true; + // Takes effect immediately when libcurl already has a connection; otherwise + // it fails harmlessly and the callbacks pause the transfer at the first + // byte instead (see paused_). + if (easy_ != nullptr) curl_easy_pause(easy_.get(), CURLPAUSE_ALL); } -BackgroundTransportCurl* CurlThread::RemoveTransfer(Response* response) { - MutexLock lock(mutex_); - auto it = transport_by_response_.find(response); - if (it == transport_by_response_.end()) return nullptr; - BackgroundTransportCurl* transport = it->second; - transport_by_response_.erase(it); - return transport; +void RestTransfer::Resume() { + paused_ = false; + // Unpauses both an explicit CURLPAUSE_ALL and a callback-initiated pause. + // Harmless when nothing was paused yet. + if (easy_ != nullptr) curl_easy_pause(easy_.get(), CURLPAUSE_CONT); } -void CurlThread::CancelAllTransfers() { - MutexLock lock(mutex_); - for (auto it = transport_by_response_.begin(); - it != transport_by_response_.end(); ++it) { - BackgroundTransportCurl* transport = it->second; - CancelRequest(transport->transport_curl(), transport->response(), - transport->curl()); - } +void RestTransfer::AbortWithoutLoop() { + canceled_ = true; + delete this; } -// The libcurl multi interface, which allows for multiple asynchronous -// transfers, requires polling to determine when transfers are complete so -// that the response may be marked completed. The polling and callbacks occur -// in this thread which is started when InitTransportCurl is called. -void CurlThread::ProcessRequests() { - // Set up multi handle. - CURLM* curl_multi = curl_multi_init(); - FIREBASE_ASSERT_MESSAGE(curl_multi != nullptr, - "curl multi handle failed to initialize"); - - int previous_running_handles = 0; - int expected_running_handles = 0; - bool quit = false; - // This will not quit until all transfers either complete or are canceled. - while (!(quit && expected_running_handles == 0)) { - int64_t polling_interval = kPollIntervalMilliseconds; - if (quit || previous_running_handles != expected_running_handles) { - // If we're quitting or the number of transfers has changed, don't wait. - polling_interval = 0; - } else if (expected_running_handles == 0) { - // If no transfers are active wait indefinitely. - polling_interval = -1; - } else { - // Curl defines the timeout argument as a long which can be a different - // size per platform so we disable the lint warning about this. - long timeout_ms = 0; // NOLINT - CURLMcode curl_code = curl_multi_timeout(curl_multi, &timeout_ms); - if (curl_code == CURLM_OK) { - if (timeout_ms < 0) timeout_ms = kPollIntervalMilliseconds; - - // Convert timeout in milliseconds to timeval. - struct timeval timeout; - timeout.tv_sec = timeout_ms / 1000; - timeout.tv_usec = (timeout_ms % 1000) * 1000; - - // Wait for curl's file descriptors to signal that data is available. - fd_set fdread; - fd_set fdwrite; - fd_set fdexcep; - int maxfd = -1; - FD_ZERO(&fdread); - FD_ZERO(&fdwrite); - FD_ZERO(&fdexcep); - curl_code = - curl_multi_fdset(curl_multi, &fdread, &fdwrite, &fdexcep, &maxfd); - if (curl_code == CURLM_OK && maxfd != -1) { - polling_interval = 0; - // TODO(smiles): This should probably also be woken up when items are - // added to the queue. If a long delay occurs due to exponential - // backoff this will hang other transfers for the delay period. - select(maxfd + 1, &fdread, &fdwrite, &fdexcep, &timeout); - } - } - } - - // Consume new transfer requests. - TransportCurlActionData action_data; - while (GetNextAction(&action_data, polling_interval)) { - polling_interval = 0; - // Act on the data. - switch (action_data.action) { - case kRequestedActionPerform: { - BackgroundTransportCurl* transport; - { - MutexLock lock(mutex_); - transport = new BackgroundTransportCurl( - curl_multi, action_data.curl, action_data.request, - action_data.response, &mutex_, action_data.controller, - action_data.transport, - [](BackgroundTransportCurl* background_transport, void* data) { - reinterpret_cast(data)->RemoveTransfer( - background_transport->response()); - }, - this); - } - AddTransfer(transport); - if (transport->PerformBackground(action_data.request)) { - expected_running_handles++; - } else { - delete transport; - } - break; - } - case kRequestedActionCancel: { - BackgroundTransportCurl* transport = - RemoveTransfer(action_data.response); - if (transport) { - transport->set_canceled(true); - delete transport; - expected_running_handles--; - } - break; - } - case kRequestedActionPause: { - MutexLock lock(mutex_); - auto it = transport_by_response_.find(action_data.response); - if (it != transport_by_response_.end()) { - curl_easy_pause(it->second->curl(), CURLPAUSE_ALL); - } - break; - } - case kRequestedActionResume: { - MutexLock lock(mutex_); - auto it = transport_by_response_.find(action_data.response); - if (it != transport_by_response_.end()) { - curl_easy_pause(it->second->curl(), CURLPAUSE_CONT); - } - break; - } - case kRequestedActionQuit: { - quit = true; - break; - } - } - } - - // Update controllers with transfer status. - { - MutexLock lock(mutex_); - for (auto it = transport_by_response_.begin(); - it != transport_by_response_.end(); ++it) { - BackgroundTransportCurl* transport = it->second; - ControllerCurl* controller = transport->controller(); - if (!controller) continue; - // Update transfer status. - CURLINFO transferred_getter; - CURLINFO size_getter; - switch (controller->direction()) { - case kTransferDirectionUpload: - size_getter = CURLINFO_CONTENT_LENGTH_UPLOAD_T; - transferred_getter = CURLINFO_SIZE_UPLOAD_T; - static_assert((CURLINFO_CONTENT_LENGTH_UPLOAD_T & - CURLINFO_TYPEMASK) == CURLINFO_OFF_T, - "Unexpected transfer_size type"); - static_assert( - (CURLINFO_SIZE_UPLOAD_T & CURLINFO_TYPEMASK) == CURLINFO_OFF_T, - "Unexpected bytes_transferred type"); - break; - case kTransferDirectionDownload: - size_getter = CURLINFO_CONTENT_LENGTH_DOWNLOAD_T; - transferred_getter = CURLINFO_SIZE_DOWNLOAD_T; - static_assert((CURLINFO_CONTENT_LENGTH_DOWNLOAD_T & - CURLINFO_TYPEMASK) == CURLINFO_OFF_T, - "Unexpected transfer_size type"); - static_assert((CURLINFO_SIZE_DOWNLOAD_T & CURLINFO_TYPEMASK) == - CURLINFO_OFF_T, - "Unexpected bytes_transferred type"); - break; - } - - CURLcode code; - curl_off_t value = 0; - code = curl_easy_getinfo(transport->curl(), size_getter, &value); - if (code == CURLE_OK) { - controller->set_transfer_size(static_cast(value)); - } - value = 0; - code = curl_easy_getinfo(transport->curl(), transferred_getter, &value); - if (code == CURLE_OK) { - controller->set_bytes_transferred(static_cast(value)); - } - } - } - - int running_handles; - curl_multi_perform(curl_multi, &running_handles); - - if (expected_running_handles != running_handles) { - // Some transfers completed. - int message_count; - CURLMsg* message; - while ((message = curl_multi_info_read(curl_multi, &message_count))) { - switch (message->msg) { - case CURLMSG_DONE: { - CURL* handle = message->easy_handle; - - // Get the response object and clean up the easy handle. - char* char_pointer; - curl_easy_getinfo(handle, CURLINFO_PRIVATE, &char_pointer); - curl_multi_remove_handle(curl_multi, handle); - BackgroundTransportCurl* transport = - reinterpret_cast(char_pointer); - - // Determine if the request timed out. - if (message->data.result == CURLE_OPERATION_TIMEDOUT) { - transport->set_timed_out(true); - } - - // Mark the response complete. - delete transport; - expected_running_handles--; - break; - } - default: { - // Should never happen. - assert(0); - } - } - } - } - previous_running_handles = expected_running_handles; +void RestTransfer::OnDone(CURLcode result) { + if (result == CURLE_OPERATION_TIMEDOUT) { + timed_out_ = true; + } else if (result == CURLE_ABORTED_BY_CALLBACK && !read_aborted_) { + // The I/O loop is shutting down with this transfer still in flight. + canceled_ = true; } - - // Clean up multi handle before returning. - curl_multi_cleanup(curl_multi); + if (result != CURLE_OK) { + LogDebug("transfer to %s finished with curl error (%d) %s: %s", + request_->options().url.c_str(), static_cast(result), + curl_easy_strerror(result), err_buf_); + } + delete this; } -void CurlThread::ProcessRequests(void* thread) { - reinterpret_cast(thread)->ProcessRequests(); -} +// --------------------------------------------------------------------------- +// TransportCurl +// --------------------------------------------------------------------------- + +TransportCurl::TransportCurl() + : is_async_(false), + running_transfers_(0), + running_transfers_semaphore_(0) {} + +TransportCurl::~TransportCurl() { WaitForAllTransfersToComplete(); } void TransportCurl::PerformInternal( Request* request, Response* response, flatbuffers::unique_ptr* controller_out) { - ControllerCurl* controller = - controller_out - ? new ControllerCurl(this, - (request->options().method == util::kGet) - ? kTransferDirectionDownload - : kTransferDirectionUpload, - response) - : nullptr; - if (controller) controller->set_transferring(true); + // Snapshot before anything below can run product code: MarkFailed() / + // MarkCompleted() are allowed to destroy this TransportCurl (see + // ~RestTransfer), so `this` must not be read after the transfer is handed + // off. + const bool is_async = is_async_; + std::shared_ptr control; + if (controller_out) { + TransferDirection direction = (request->options().method == util::kGet) + ? kTransferDirectionDownload + : kTransferDirectionUpload; + control = std::make_shared(direction); + // Normally we would use make_new() here, but this is not a std::unique_ptr + // and make_new() isn't supported by all targets we build for. Published + // before the transfer starts so Controller calls work even before the I/O + // thread has picked the transfer up. + controller_out->reset(new ControllerCurl(control)); // NOLINT + } { MutexLock lock(running_transfers_mutex_); running_transfers_++; } - g_curl_thread->ScheduleAction(TransportCurlActionData::Perform( - this, request, response, reinterpret_cast(curl_), controller)); - if (controller_out) { - // Normally we would use make_new() here, but this is not a std::unique_ptr - // and make_new() isn't supported by all targets we build for - controller_out->reset(controller); // NOLINT + + if (!is_async && curl::CurlIoLoop::IsCurrentThreadIoThread()) { + // A synchronous Perform() from the I/O thread itself (e.g. from inside a + // Response::MarkCompleted() override) can never complete: the thread that + // would run the transfer is the one that would block here. Fail the + // request instead of hanging every transfer in the process. + LogError( + "Synchronous TransportCurl::Perform() called on the curl I/O thread; " + "failing the request. Use set_is_async(true) or move the call to " + "another thread."); + FIREBASE_DEV_ASSERT_MESSAGE(false, + "Synchronous TransportCurl::Perform() on " + "the curl I/O thread"); + RestTransfer* transfer = new RestTransfer(this, request, response, control); + transfer->AbortWithoutLoop(); + return; + } + + // Wires up TransferControl now so that Controller::Cancel() etc. work even + // before the I/O thread has picked the transfer up. + RestTransfer* transfer = new RestTransfer(this, request, response, control); + if (!curl::CurlIoLoop::PostIfRunning([transfer]() { transfer->Start(); })) { + // The loop is not running (Perform() before InitTransportCurl() or + // after/racing with CleanupTransportCurl()). Fail the request instead of + // asserting or leaving the caller waiting forever. + transfer->AbortWithoutLoop(); } // TODO(b/67899466): Remove this once we transition to exclusively // asynchronous curl requests. - if (!is_async_) WaitForAllTransfersToComplete(); -} - -void TransportCurl::CancelRequest(Response* response) { - int removed_from_queue = g_curl_thread->CancelRequest( - this, response, reinterpret_cast(curl_)); - while (removed_from_queue--) SignalTransferComplete(); -} - -void TransportCurl::PauseRequest(Response* response) { - g_curl_thread->ScheduleAction(TransportCurlActionData::Pause( - this, response, reinterpret_cast(curl_))); -} - -void TransportCurl::ResumeRequest(Response* response) { - g_curl_thread->ScheduleAction(TransportCurlActionData::Resume( - this, response, reinterpret_cast(curl_))); + if (!is_async) { + WaitForAllTransfersToComplete(); + } } void TransportCurl::SignalTransferComplete() { diff --git a/app/rest/transport_curl.h b/app/rest/transport_curl.h index 6c9c569755..d0225baf91 100644 --- a/app/rest/transport_curl.h +++ b/app/rest/transport_curl.h @@ -31,9 +31,12 @@ namespace rest { // This must be called before performing any curl operations. Calls to this // function are reference counted, so it is safe to call multiple times. +// Starts the shared curl::CurlIoLoop I/O thread on the first call. void InitTransportCurl(); // This must be called when curl operations are complete to clean up remaining // resources. This should be called once for every call to InitTransportCurl. +// Stops the I/O thread on the last call; any transfer still in flight is +// failed (HttpNoContent) before this returns. void CleanupTransportCurl(); // Implement the transport layer, based on curl library. @@ -72,24 +75,13 @@ class TransportCurl : public Transport { flatbuffers::unique_ptr* controller_out) override; private: - friend class ControllerCurl; - friend class BackgroundTransportCurl; - - // Used by the ControllerCurl to cancel a running transfer. - void CancelRequest(Response* response); - // Used by the ControllerCurl to pause a running transfer. - void PauseRequest(Response* response); - // Used by the ControllerCurl to resumt a running transfer. - void ResumeRequest(Response* response); + friend class RestTransfer; // Signal that a scheduled transfer is complete or canceled. void SignalTransferComplete(); // Wait for all requests associated with this transport to complete. void WaitForAllTransfersToComplete(); - // The Curl handle. This class owns the handle. - void* curl_; - // Whether this request should be made asynchronously. bool is_async_; diff --git a/app/rest/util.cc b/app/rest/util.cc index 10bb146b57..e5ae5c1fab 100644 --- a/app/rest/util.cc +++ b/app/rest/util.cc @@ -117,6 +117,13 @@ std::string ToUpper(const std::string& str) { return res; } +std::string ToLower(const std::string& str) { + std::string res = str; + std::transform(res.begin(), res.end(), res.begin(), + [](unsigned char c) { return std::tolower(c); }); + return res; +} + bool JsonData::Parse(const char* json_txt) { root_ = firebase::util::JsonToVariant(json_txt); return !root_.is_null(); diff --git a/app/rest/util.h b/app/rest/util.h index 32c543aa59..3067f9c9a0 100644 --- a/app/rest/util.h +++ b/app/rest/util.h @@ -76,6 +76,9 @@ std::string TrimWhitespace(const std::string& str); // Change to upper cases. std::string ToUpper(const std::string& str); +// Change to lower cases. +std::string ToLower(const std::string& str); + // Apply URL encoding to a string. std::string EncodeUrl(const std::string& path); diff --git a/app/src/logger.cc b/app/src/logger.cc index 7bbfb5791e..c7f6716529 100644 --- a/app/src/logger.cc +++ b/app/src/logger.cc @@ -95,7 +95,9 @@ LogLevel Logger::GetLogLevel() const { return log_level_; } void Logger::LogMessageImplV(LogLevel log_level, const char* format, va_list args) const { - parent_logger_->LogMessageV(log_level, format, args); + if (parent_logger_ != nullptr) { + parent_logger_->LogMessageV(log_level, format, args); + } } } // namespace firebase diff --git a/app/tests/logger_test.cc b/app/tests/logger_test.cc index 9b6e6855f4..926fc6aad6 100644 --- a/app/tests/logger_test.cc +++ b/app/tests/logger_test.cc @@ -63,6 +63,10 @@ TEST(LoggerTest, GetSetLogLevel) { EXPECT_EQ(logger2.GetLogLevel(), kLogLevelDebug); logger2.SetLogLevel(kLogLevelInfo); EXPECT_EQ(logger2.GetLogLevel(), kLogLevelInfo); + + // Logging at any level with a null parent logger must be a safe no-op. + logger.LogWarning("ignored warning"); + logger.LogError("ignored error"); } TEST(LoggerTest, LogWithEachFunction) { diff --git a/app_check/integration_test/CMakeLists.txt b/app_check/integration_test/CMakeLists.txt index fd9e6deb48..34591dc2c2 100644 --- a/app_check/integration_test/CMakeLists.txt +++ b/app_check/integration_test/CMakeLists.txt @@ -211,7 +211,7 @@ else() "-framework SystemConfiguration" ) elseif(MSVC) - set(ADDITIONAL_LIBS advapi32 ws2_32 crypt32 bcrypt iphlpapi secur32 psapi userenv shell32) + set(ADDITIONAL_LIBS advapi32 ws2_32 crypt32 bcrypt iphlpapi secur32 shell32) else() set(ADDITIONAL_LIBS pthread) endif() diff --git a/app_check/integration_test/src/integration_test.cc b/app_check/integration_test/src/integration_test.cc index e81bc6d784..9ae9cdc11d 100644 --- a/app_check/integration_test/src/integration_test.cc +++ b/app_check/integration_test/src/integration_test.cc @@ -170,8 +170,11 @@ void FirebaseAppCheckTest::InitializeAppCheckWithDebug() { LogDebug("Initialize Firebase App Check with Debug Provider"); // Set the App Check Debug Token before providing the Factory. + const char* env_debug_token = std::getenv("APP_CHECK_DEBUG_TOKEN"); firebase::app_check::DebugAppCheckProviderFactory::GetInstance() - ->SetDebugToken(kAppCheckDebugToken); + ->SetDebugToken((env_debug_token && *env_debug_token) + ? env_debug_token + : kAppCheckDebugToken); firebase::app_check::AppCheck::SetAppCheckProviderFactory( firebase::app_check::DebugAppCheckProviderFactory::GetInstance()); @@ -877,4 +880,38 @@ TEST_F(FirebaseAppCheckTest, TestFunctionsSuccess) { } } +// Verifies concurrent multi-App App Check debug token exchanges over the shared +// process-wide CurlIoLoop without cross-handle contention or failure. +TEST_F(FirebaseAppCheckTest, TestConcurrentMultiAppTokenExchange) { + InitializeAppCheckWithDebug(); + InitializeApp(); + + const int kNumApps = 6; + std::vector apps(kNumApps, nullptr); + std::vector checks(kNumApps, nullptr); + std::vector> futures( + kNumApps); + + for (int i = 0; i < kNumApps; ++i) { + std::string name = "concurrent_app_check_" + std::to_string(i); + apps[i] = firebase::App::Create(app_->options(), name.c_str()); + ASSERT_NE(apps[i], nullptr); + checks[i] = firebase::app_check::AppCheck::GetInstance(apps[i]); + ASSERT_NE(checks[i], nullptr); + futures[i] = checks[i]->GetAppCheckToken(/*force_refresh=*/true); + } + + for (int i = 0; i < kNumApps; ++i) { + EXPECT_TRUE(WaitForCompletion(futures[i], "MultiApp GetAppCheckToken")); + ASSERT_NE(futures[i].result(), nullptr); + EXPECT_FALSE(futures[i].result()->token.empty()); + EXPECT_GT(futures[i].result()->expire_time_millis, 0); + } + + for (int i = 0; i < kNumApps; ++i) { + delete checks[i]; + delete apps[i]; + } +} + } // namespace firebase_testapp_automated diff --git a/auth/integration_test/src/integration_test.cc b/auth/integration_test/src/integration_test.cc index 0e71720735..a36904ad0e 100644 --- a/auth/integration_test/src/integration_test.cc +++ b/auth/integration_test/src/integration_test.cc @@ -21,6 +21,7 @@ #include #include #include +#include #include "app_framework.h" // NOLINT #include "firebase/app.h" @@ -1571,4 +1572,56 @@ TEST_F(FirebaseAuthTest, TestLinkFederatedProviderBadProviderIdFails) { #endif // defined(ENABLE_OAUTH_TESTS) +// Verifies that multiple Firebase App + Auth instances can execute concurrent +// sign-in and forced token-refresh RPCs simultaneously over the shared +// process-wide CurlIoLoop without cross-handle leakage or contention failures. +TEST_F(FirebaseAuthTest, TestConcurrentMultiAppSignInAndTokenRefresh) { + ASSERT_NE(app_, nullptr); + + const int kNumApps = 6; + std::vector apps(kNumApps, nullptr); + std::vector auths(kNumApps, nullptr); + std::vector> sign_in_futures( + kNumApps); + + for (int i = 0; i < kNumApps; ++i) { + std::string name = "concurrent_auth_app_" + std::to_string(i); + apps[i] = firebase::App::Create(app_->options(), name.c_str()); + ASSERT_NE(apps[i], nullptr); + firebase::InitResult init_result = firebase::kInitResultSuccess; + auths[i] = firebase::auth::Auth::GetAuth(apps[i], &init_result); + ASSERT_EQ(init_result, firebase::kInitResultSuccess); + ASSERT_NE(auths[i], nullptr); + sign_in_futures[i] = auths[i]->SignInAnonymously(); + } + + for (int i = 0; i < kNumApps; ++i) { + FirebaseTest::WaitForCompletion(sign_in_futures[i], + "MultiApp SignInAnonymously"); + ASSERT_TRUE(auths[i]->current_user().is_valid()); + } + + std::vector> token_futures(kNumApps); + for (int i = 0; i < kNumApps; ++i) { + token_futures[i] = + auths[i]->current_user().GetToken(/*force_refresh=*/true); + } + for (int i = 0; i < kNumApps; ++i) { + FirebaseTest::WaitForCompletion(token_futures[i], + "MultiApp GetToken(true)"); + ASSERT_NE(token_futures[i].result(), nullptr); + EXPECT_FALSE(token_futures[i].result()->empty()); + } + + std::vector> delete_futures(kNumApps); + for (int i = 0; i < kNumApps; ++i) { + delete_futures[i] = auths[i]->current_user().Delete(); + } + for (int i = 0; i < kNumApps; ++i) { + FirebaseTest::WaitForCompletion(delete_futures[i], "MultiApp User::Delete"); + delete auths[i]; + delete apps[i]; + } +} + } // namespace firebase_testapp_automated diff --git a/build_scripts/desktop/package.sh b/build_scripts/desktop/package.sh index a741931e6e..d58de21eb9 100755 --- a/build_scripts/desktop/package.sh +++ b/build_scripts/desktop/package.sh @@ -220,8 +220,6 @@ readonly deps_hidden_firebase_app=" */firestore-build/*/nanopb-build*/${prefix}*.${ext} " readonly deps_hidden_firebase_database=" -*/${subdir}${prefix}uv_a.${ext} -*/${subdir}${prefix}libuWS.${ext} " readonly deps_hidden_firebase_firestore=" */firestore-build/Firestore/*/${prefix}*.${ext} @@ -375,17 +373,33 @@ for product in ${product_list[*]}; do echo "rm -f ${deps[*]}" >> "${merge_libraries_tmp}/merge_${product}.sh" fi chmod u+x "${merge_libraries_tmp}/merge_${product}.sh" - if [[ ${run_in_parallel} -eq 0 ]]; then - # Run immediately if not set to run in parallel. - "${merge_libraries_tmp}/merge_${product}.sh" - else + if [[ ${run_in_parallel} -ne 0 ]]; then echo "echo \"${libfile_out}\" DONE" >> "${merge_libraries_tmp}/merge_${product}.sh" fi done -if [[ ${run_in_parallel} -ne 0 ]]; then - # Analytics is the smallest SDK, so it should be the shortest job. +if [[ ${run_in_parallel} -eq 0 ]]; then + # Run sequentially only after all merge_${product}.sh scripts are generated so + # `find . -path ${dep}` above resolves shared hidden deps (e.g. libcurl.a in + # deps_hidden_firebase_app) before merge_app.sh deletes them under -D. The + # first script to run populates ${cache_file} from --scan_libs="${allfiles}". + for product in ${product_list[*]}; do + if [[ -x "${merge_libraries_tmp}/merge_${product}.sh" ]]; then + "${merge_libraries_tmp}/merge_${product}.sh" + fi + done +else + # Analytics is the smallest SDK, so it should be the shortest job; fall back + # to the first built product if analytics was excluded from the CMake build. shortest=analytics + if [[ ! -x "${merge_libraries_tmp}/merge_${shortest}.sh" ]]; then + for product in ${product_list[*]}; do + if [[ -x "${merge_libraries_tmp}/merge_${product}.sh" ]]; then + shortest="${product}" + break + fi + done + fi echo "There are ${#product_list[@]} jobs to run." echo "Running shortest job to populate cache, then remaining jobs in parallel..." "${merge_libraries_tmp}/merge_${shortest}.sh" diff --git a/cmake/CTestCustom.cmake b/cmake/CTestCustom.cmake index c233161fa6..5946957f71 100644 --- a/cmake/CTestCustom.cmake +++ b/cmake/CTestCustom.cmake @@ -15,9 +15,6 @@ # Define the tests that need to be ignored. set(CTEST_CUSTOM_TESTS_IGNORE ${CTEST_CUSTOM_TESTS_IGNORE} - # Tests from libuv, that can't be disabled normally. - uv_test - uv_test_a # Tests from zlib, that can't be disabled normally. example example64 diff --git a/cmake/external/CMakeLists.txt b/cmake/external/CMakeLists.txt index 2ca2da355c..3cfdd8e830 100644 --- a/cmake/external/CMakeLists.txt +++ b/cmake/external/CMakeLists.txt @@ -30,9 +30,7 @@ include(firestore) if (${FIREBASE_EXTERNAL_PLATFORM} STREQUAL "DESKTOP") include(boringssl) include(curl) - include(libuv) include(leveldb) - include(uWebSockets) include(zlib) endif() diff --git a/cmake/external/libuv.cmake b/cmake/external/libuv.cmake deleted file mode 100644 index 162358ad1e..0000000000 --- a/cmake/external/libuv.cmake +++ /dev/null @@ -1,37 +0,0 @@ -# Copyright 2019 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -include(ExternalProject) - -if(TARGET libuv OR NOT DOWNLOAD_LIBUV) - return() -endif() - -set(version v1.33.1) - -ExternalProject_Add( - libuv - - DOWNLOAD_DIR ${FIREBASE_DOWNLOAD_DIR} - DOWNLOAD_NAME libuv-${version}.tar.gz - URL https://github.com/libuv/libuv/archive/${version}.tar.gz - - PREFIX ${PROJECT_BINARY_DIR} - - CONFIGURE_COMMAND "" - BUILD_COMMAND "" - INSTALL_COMMAND "" - TEST_COMMAND "" - HTTP_HEADER "${EXTERNAL_PROJECT_HTTP_HEADER}" -) diff --git a/cmake/external/uWebSockets.cmake b/cmake/external/uWebSockets.cmake deleted file mode 100644 index 0efd51d1eb..0000000000 --- a/cmake/external/uWebSockets.cmake +++ /dev/null @@ -1,39 +0,0 @@ -# Copyright 2019 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -include(ExternalProject) - -if(TARGET uWebSockets OR NOT DOWNLOAD_UWEBSOCKETS) - return() -endif() - -set(uwebsockets_commit_tag 4d94401b9c98346f9afd838556fdc7dce30561eb) -set(patch_file - ${CMAKE_CURRENT_LIST_DIR}/../../scripts/git/patches/uWebSockets/0001-fix-want-write-and-sprintf-deprecation.patch) - -ExternalProject_Add( - uWebSockets - - DOWNLOAD_COMMAND - COMMAND git init uWebSockets - COMMAND cd uWebSockets && git fetch --depth=1 https://github.com/uNetworking/uWebSockets.git ${uwebsockets_commit_tag} && git reset --hard FETCH_HEAD - - PATCH_COMMAND git apply ${patch_file} && git gc --aggressive - PREFIX ${PROJECT_BINARY_DIR} - CONFIGURE_COMMAND "" - BUILD_COMMAND "" - INSTALL_COMMAND "" - TEST_COMMAND "" - HTTP_HEADER "${EXTERNAL_PROJECT_HTTP_HEADER}" -) diff --git a/cmake/external_rules.cmake b/cmake/external_rules.cmake index 4a3d846883..813f9a779c 100644 --- a/cmake/external_rules.cmake +++ b/cmake/external_rules.cmake @@ -49,8 +49,6 @@ function(download_external_sources) check_use_local_directory(BORINGSSL) check_use_local_directory(CURL) check_use_local_directory(FLATBUFFERS) - check_use_local_directory(LIBUV) - check_use_local_directory(UWEBSOCKETS) check_use_local_directory(ZLIB) check_use_local_directory(FIREBASE_IOS_SDK) @@ -83,8 +81,6 @@ function(download_external_sources) -DDOWNLOAD_CURL=${DOWNLOAD_CURL} -DDOWNLOAD_FLATBUFFERS=${DOWNLOAD_FLATBUFFERS} -DDOWNLOAD_GOOGLETEST=${FIREBASE_DOWNLOAD_GTEST} - -DDOWNLOAD_LIBUV=${DOWNLOAD_LIBUV} - -DDOWNLOAD_UWEBSOCKETS=${DOWNLOAD_UWEBSOCKETS} -DDOWNLOAD_ZLIB=${DOWNLOAD_ZLIB} -DDOWNLOAD_FIREBASE_IOS_SDK=${DOWNLOAD_FIREBASE_IOS_SDK} -DEXTERNAL_PROJECT_HTTP_HEADER=${EXTERNAL_PROJECT_HTTP_HEADER} diff --git a/database/CMakeLists.txt b/database/CMakeLists.txt index 1df98b1f3e..a6b07b3ddf 100644 --- a/database/CMakeLists.txt +++ b/database/CMakeLists.txt @@ -165,15 +165,14 @@ if(ANDROID OR IOS) set(additional_DEFINES) else() set(additional_include_DIR + ${CURL_SOURCE_DIR}/include ${FLATBUFFERS_SOURCE_DIR}/include - ${OPENSSL_INCLUDE_DIR} - ${UWEBSOCKETS_SOURCE_DIR}/..) + ${OPENSSL_INCLUDE_DIR}) set(additional_link_LIB firebase_rest_lib leveldb - libuWS - OpenSSL::SSL + libcurl OpenSSL::Crypto) set(additional_DEFINES @@ -186,6 +185,7 @@ if(MSVC) -DWIN32_LEAN_AND_MEAN # Ensure that windows doesn't include winsock.h by # default, as it can cause issues when libraries try # to include winsock2.h later on in the process. + -DCURL_STATICLIB=1 ) endif() @@ -246,7 +246,3 @@ endif() cpp_pack_library(firebase_database "") cpp_pack_public_headers() -if (NOT ANDROID AND NOT IOS) - cpp_pack_library(uv_a "deps/database/external") - cpp_pack_library(libuWS "deps/database/external") -endif() diff --git a/database/integration_test/CMakeLists.txt b/database/integration_test/CMakeLists.txt index 09fc404bec..a21079377d 100644 --- a/database/integration_test/CMakeLists.txt +++ b/database/integration_test/CMakeLists.txt @@ -211,7 +211,7 @@ else() "-framework SystemConfiguration" ) elseif(MSVC) - set(ADDITIONAL_LIBS advapi32 ws2_32 crypt32 bcrypt iphlpapi secur32 psapi userenv shell32) + set(ADDITIONAL_LIBS advapi32 ws2_32 crypt32 bcrypt iphlpapi secur32 shell32) else() set(ADDITIONAL_LIBS pthread) endif() diff --git a/database/integration_test/src/integration_test.cc b/database/integration_test/src/integration_test.cc index 23e1497b78..f0552b10a0 100644 --- a/database/integration_test/src/integration_test.cc +++ b/database/integration_test/src/integration_test.cc @@ -20,7 +20,9 @@ #include #include #include +#include #include +#include #include "app_framework.h" // NOLINT #include "firebase/app.h" @@ -30,6 +32,11 @@ #include "firebase/util.h" #include "firebase_test_framework.h" // NOLINT +#if defined(__linux__) && FIREBASE_PLATFORM_DESKTOP +#include +#include +#endif + // The TO_STRING macro is useful for command line defined strings as the quotes // get stripped. #define TO_STRING_EXPAND(X) #X @@ -1440,4 +1447,155 @@ TEST_F(FirebaseDatabaseTest, TestChildListenerWithNullArgument) { ref.AddChildListener(nullptr); } +#if defined(__linux__) && FIREBASE_PLATFORM_DESKTOP +static int CountProcessThreads() { + DIR* dir = opendir("/proc/self/task"); + if (!dir) return -1; + int count = 0; + while (struct dirent* entry = readdir(dir)) { + if (entry->d_name[0] != '.') ++count; + } + closedir(dir); + return count; +} + +static int WaitForProcessThreadsAtMost(int max_expected) { + int count = CountProcessThreads(); + for (int i = 0; i < 200 && count > max_expected; ++i) { + usleep(5000); + count = CountProcessThreads(); + } + return count; +} +#endif // defined(__linux__) && defined(FIREBASE_PLATFORM_DESKTOP) + +namespace { +class BoolFlagValueListener : public firebase::database::ValueListener { + public: + void OnValueChanged( + const firebase::database::DataSnapshot& snapshot) override { + if (snapshot.value().is_bool() && snapshot.value().bool_value()) { + connected_ = true; + } + } + void OnCancelled(const firebase::database::Error& /*error*/, + const char* /*error_message*/) override {} + bool connected() const { return connected_; } + + private: + bool connected_ = false; +}; +} // namespace + +// Verifies that multiple Firebase Apps and Database instances can hold live +// WebSocket connections concurrently and join all WebSocket and App threads +// cleanly on teardown without leaking background threads. +TEST_F(FirebaseDatabaseTest, TestMultipleAppsConcurrentWebSocketLifecycle) { + ASSERT_NE(shared_app_, nullptr); + ASSERT_NE(database_, nullptr); + + // Ensure the primary database connection (and shared Repo scheduler) is warm + // before measuring baseline process threads. + firebase::database::DatabaseReference primary_info = + database_->GetReference(".info/connected"); + BoolFlagValueListener primary_listener; + primary_info.AddValueListener(&primary_listener); + for (int i = 0; i < 100 && !primary_listener.connected(); ++i) { + ProcessEvents(100); + } + EXPECT_TRUE(primary_listener.connected()); + +#if defined(__linux__) && FIREBASE_PLATFORM_DESKTOP + const int threads_baseline = CountProcessThreads(); + ASSERT_GT(threads_baseline, 0); +#endif + + const int kExtraApps = 8; + std::vector apps(kExtraApps, nullptr); + std::vector dbs(kExtraApps, nullptr); + std::vector refs(kExtraApps); + std::vector listeners(kExtraApps); + + for (int i = 0; i < kExtraApps; ++i) { + std::string name = "multi_ws_app_" + std::to_string(i); + apps[i] = firebase::App::Create(shared_app_->options(), name.c_str()); + ASSERT_NE(apps[i], nullptr); + } + ProcessEvents(100); + + for (int i = 0; i < kExtraApps; ++i) { + firebase::InitResult init_result = firebase::kInitResultSuccess; + dbs[i] = firebase::database::Database::GetInstance(apps[i], &init_result); + ASSERT_EQ(init_result, firebase::kInitResultSuccess); + ASSERT_NE(dbs[i], nullptr); + refs[i] = dbs[i]->GetReference(".info/connected"); + refs[i].AddValueListener(&listeners[i]); + } + + bool all_connected = false; + for (int attempt = 0; attempt < 150 && !all_connected; ++attempt) { + ProcessEvents(100); + all_connected = true; + for (int i = 0; i < kExtraApps; ++i) { + if (!listeners[i].connected()) { + all_connected = false; + break; + } + } + } + EXPECT_TRUE(all_connected); + + primary_info.RemoveValueListener(&primary_listener); + for (int i = 0; i < kExtraApps; ++i) { + refs[i].RemoveValueListener(&listeners[i]); + refs[i] = firebase::database::DatabaseReference(); + delete dbs[i]; + delete apps[i]; + } + +#if defined(__linux__) && FIREBASE_PLATFORM_DESKTOP + // Allow any ephemeral async DNS resolver threads spawned during handshake to + // finish detaching, and verify zero WebSocket or App threads leaked. + const int threads_after_cleanup = + WaitForProcessThreadsAtMost(threads_baseline + 1); + EXPECT_LE(threads_after_cleanup, threads_baseline + 1) + << "threads_baseline=" << threads_baseline + << " threads_after_cleanup=" << threads_after_cleanup; +#endif +} + +// Verifies concurrent multi-frame WebSocket writes and reads across many paths +// without framing corruption or head-of-line stalls. +TEST_F(FirebaseDatabaseTest, TestConcurrentMultiFrameWritesAndReads) { + SignIn(); + firebase::database::DatabaseReference working_path = CreateWorkingPath(); + + const int kNumChildren = 12; + const size_t kPayloadBytes = 32 * 1024; + std::vector payloads(kNumChildren); + std::vector> write_futures(kNumChildren); + for (int i = 0; i < kNumChildren; ++i) { + payloads[i].assign(kPayloadBytes, static_cast('A' + (i % 26))); + std::string child_name = "chunk_" + std::to_string(i); + write_futures[i] = + working_path.Child(child_name.c_str()).SetValue(payloads[i].c_str()); + } + for (int i = 0; i < kNumChildren; ++i) { + WaitForCompletion(write_futures[i], "Concurrent SetValue"); + } + + std::vector> read_futures( + kNumChildren); + for (int i = 0; i < kNumChildren; ++i) { + std::string child_name = "chunk_" + std::to_string(i); + read_futures[i] = working_path.Child(child_name.c_str()).GetValue(); + } + for (int i = 0; i < kNumChildren; ++i) { + WaitForCompletion(read_futures[i], "Concurrent GetValue"); + ASSERT_NE(read_futures[i].result(), nullptr); + ASSERT_TRUE(read_futures[i].result()->value().is_string()); + EXPECT_EQ(payloads[i], read_futures[i].result()->value().string_value()); + } +} + } // namespace firebase_testapp_automated diff --git a/database/src/desktop/connection/util_connection.cc b/database/src/desktop/connection/util_connection.cc index dbac9b53dd..f8a39b5bb4 100644 --- a/database/src/desktop/connection/util_connection.cc +++ b/database/src/desktop/connection/util_connection.cc @@ -27,7 +27,6 @@ std::unique_ptr CreateWebSocketClient( const HostInfo& info, WebSocketClientEventHandler* delegate, const char* opt_last_session_id, Logger* logger, scheduler::Scheduler* scheduler, const std::string& app_check_token) { - // Currently we use uWebSockets implementation. std::string uri = info.GetConnectionUrl(opt_last_session_id); return std::make_unique( uri, info.user_agent(), logger, scheduler, app_check_token, delegate); diff --git a/database/src/desktop/connection/web_socket_client_impl.cc b/database/src/desktop/connection/web_socket_client_impl.cc index 2195c0c03a..3526e003b8 100644 --- a/database/src/desktop/connection/web_socket_client_impl.cc +++ b/database/src/desktop/connection/web_socket_client_impl.cc @@ -1,301 +1,644 @@ -// Copyright 2018 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. +/* + * Copyright 2017 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ #include "database/src/desktop/connection/web_socket_client_impl.h" +#include #include -#include - +#include // NOLINT +#include +#include +#include +#include + +#include "app/rest/curl/curl_handles.h" +#include "app/rest/curl/tls_policy.h" #include "app/src/app_common.h" #include "app/src/assert.h" #include "app/src/include/firebase/app.h" #include "app/src/include/firebase/internal/mutex.h" -#include "app/src/log.h" -#include "app/src/thread.h" +#include "curl/curl.h" namespace firebase { namespace database { namespace internal { namespace connection { +using ::firebase::rest::curl::AcquireCurlGlobal; +using ::firebase::rest::curl::ReleaseCurlGlobal; +using ::firebase::rest::curl::UniqueEasy; + +namespace { +// Size of the stack buffer used for each curl_ws_recv() read pass. +constexpr size_t kRecvBufferSize = 16384; +// RFC 6455 section 5.5 caps all WebSocket control frame payloads at 125 bytes +// and forbids fragmenting control frames. +constexpr size_t kMaxPingPayloadBytes = 125; +// Upper bound on a single reassembled inbound WebSocket message (256 MiB, +// matching Realtime Database's single-read payload limit) so a misbehaving peer +// streaming CURLWS_CONT fragments cannot grow memory without bound. +constexpr size_t kMaxMessagePayloadBytes = 256 * 1024 * 1024; +// WebSocket close code 1000 (Normal Closure) in network byte order. +const unsigned char kCloseNormalPayload[2] = {0x03, 0xe8}; +// Upper bound on how long the I/O thread sleeps in curl_multi_poll() when +// nothing happens. Post() wakes it immediately via curl_multi_wakeup(). +constexpr int kPollTimeoutMs = 1000; +} // namespace + WebSocketClientImpl::WebSocketClientImpl( const std::string& uri, const std::string& user_agent, Logger* logger, scheduler::Scheduler* scheduler, const std::string& app_check_token, WebSocketClientEventHandler* handler /*=nullptr*/) : uri_(uri), handler_(handler), - thread_(nullptr), - hub_(), - keep_loop_alive_(nullptr), - process_queue_async_(nullptr), - callback_queue_(), - callback_queue_mutex_(Mutex::kModeNonRecursive), - is_destructing_(0), - websocket_(nullptr), + multi_(nullptr), + post_mutex_(Mutex::kModeNonRecursive), + posted_(), + quit_(false), + easy_(nullptr), + request_headers_(nullptr), + socket_(CURL_SOCKET_BAD), + state_(kStateIdle), + handshake_deadline_(std::chrono::steady_clock::time_point::max()), + incoming_message_buffer_(), + incoming_ping_buffer_(), + send_queue_(), + frame_in_progress_(false), + current_send_offset_(0), user_agent_(user_agent), + app_check_token_mutex_(Mutex::kModeNonRecursive), + app_check_token_(app_check_token), logger_(logger), scheduler_(scheduler), safe_this_(this), - app_check_token_(app_check_token) { - // Bind callback function - hub_.onError(WebSocketClientImpl::OnError); - hub_.onConnection(WebSocketClientImpl::OnConnection); - hub_.onMessage(WebSocketClientImpl::OnMessage); - hub_.onDisconnection(WebSocketClientImpl::OnDisconnection); - - // Create a async object to keep the loop alive and close all async handler - // during destruction. - keep_loop_alive_ = new uS::Async(hub_.getLoop()); - keep_loop_alive_->setData(this); - keep_loop_alive_->start([](uS::Async* async) { - assert(async); - assert(async->getData()); - - WebSocketClientImpl* client = - static_cast(async->getData()); - - // Close all async process - if (client) { - client->CloseSync(); - - client->keep_loop_alive_->close(); - client->keep_loop_alive_ = nullptr; - client->process_queue_async_->close(); - client->process_queue_async_ = nullptr; - } - }); - - // Initiate async handler to process callback queue. The callback will only - // be triggered after process_queue_async_->send(). - process_queue_async_ = new uS::Async(hub_.getLoop()); - process_queue_async_->setData(this); - process_queue_async_->start(ProcessCallbackQueue); - - // Start the event loop - thread_ = std::make_unique(EventLoopRoutine, this); -} - -void WebSocketClientImpl::EventLoopRoutine(void* data) { - assert(data != nullptr); - WebSocketClientImpl* client = static_cast(data); - Logger* logger = client->logger_; - - logger->LogDebug("=== uWebSockets Event Loop Start ==="); - client->hub_.run(); - logger->LogDebug("=== uWebSockets Event Loop End ==="); + thread_() { + AcquireCurlGlobal(); + multi_.reset(curl_multi_init()); + FIREBASE_ASSERT_MESSAGE(multi_ != nullptr, "curl_multi_init() failed"); + // Started last: everything Run() touches is initialized above. + thread_ = Thread(ThreadMain, this); } WebSocketClientImpl::~WebSocketClientImpl() { - // Clear safe reference immediately so that scheduled callback can skip - // executing code which requires reference to this. + // Clear safe reference immediately so that scheduled callbacks can skip + // executing code which requires a reference to this. safe_this_.ClearReference(); - is_destructing_.store(1); + // Release the socket and the easy handle on the I/O thread, then stop it. + // Nothing touches this object from the I/O thread once Join() returns. + Post([this]() { + TeardownOnLoop(); + quit_ = true; + }); + thread_.Join(); - // Remove the handler to keep event loop alive - if (keep_loop_alive_ != nullptr) { - keep_loop_alive_->send(); - } + handler_ = nullptr; + assert(easy_ == nullptr); + assert(socket_ == CURL_SOCKET_BAD); - // Wait for the thread to end. - if (thread_) { - thread_->Join(); - thread_.reset(nullptr); - } + multi_.reset(); + ReleaseCurlGlobal(); +} - handler_ = nullptr; +// --------------------------------------------------------------------------- +// Cross-thread entry points. +// --------------------------------------------------------------------------- - // websocket_ should be cleared now or OnDisconnection() probably is not - // called properly. - assert(websocket_ == nullptr); +void WebSocketClientImpl::Post(std::function fn) { + MutexLock lock(post_mutex_); + posted_.push_back(std::move(fn)); + // curl_multi_wakeup() is thread-safe; multi_ outlives the I/O thread. + curl_multi_wakeup(multi_.get()); } void WebSocketClientImpl::Connect(int timeout_ms) { - ScheduleOnce( - [](WebSocketClientImpl* client, int timeout_ms, const std::string&) { - Logger* logger = client->logger_; - if (client->websocket_ == nullptr) { - std::map headers; - headers["User-Agent"] = client->user_agent_; - headers[app_common::kApiClientHeader] = App::GetUserAgent(); - if (!client->app_check_token_.empty()) { - headers["X-Firebase-AppCheck"] = client->app_check_token_; - } - client->hub_.connect(client->uri_, client, headers, timeout_ms); - } else { - logger->LogWarning("websocket has already been connected to %s", - client->uri_.c_str()); - } - }, - timeout_ms, ""); + Post([this, timeout_ms]() { ConnectOnLoop(timeout_ms); }); } void WebSocketClientImpl::Close() { - ScheduleOnce([](WebSocketClientImpl* client, int, - const std::string&) { client->CloseSync(); }, - 0, ""); -} - -void WebSocketClientImpl::CloseSync() { - if (IsWebSocketAvailable()) { - websocket_->close(1000); - } + Post([this]() { CloseOnLoop(); }); } void WebSocketClientImpl::Send(const char* msg) { assert(msg != nullptr); - - ScheduleOnce( - [](WebSocketClientImpl* client, int, const std::string& msg) { - Logger* logger = client->logger_; - if (client->IsWebSocketAvailable()) { - client->websocket_->send(msg.c_str()); - } else { - logger->LogWarning( - "Cannot send message. websocket is not available"); - } - }, - 0, msg); + std::string message(msg); + Post([this, message = std::move(message)]() { SendOnLoop(message); }); } void WebSocketClientImpl::RefreshAppCheckToken(const std::string& token) { + MutexLock lock(app_check_token_mutex_); app_check_token_ = token; } -void WebSocketClientImpl::OnError(void* data) { - assert(data != nullptr); - WebSocketClientImpl* client = static_cast(data); - Logger* logger = client->logger_; +// --------------------------------------------------------------------------- +// The I/O thread. +// --------------------------------------------------------------------------- + +void WebSocketClientImpl::ThreadMain(void* client) { + static_cast(client)->Run(); +} - if (client->handler_) { - // TODO(b/71873743): Modify uWebSockets to provide more context, ex. reasons - WebSocketClientErrorData error(client->uri_.c_str()); - client->handler_->OnError(error); +void WebSocketClientImpl::RunPosted() { + std::deque> batch; + { + MutexLock lock(post_mutex_); + batch.swap(posted_); } + for (std::function& fn : batch) fn(); +} + +void WebSocketClientImpl::Run() { + while (!quit_) { + RunPosted(); + if (quit_) break; + + // Drive the handshake (a no-op once the connect-only handle is done). + int running_handles = 0; + curl_multi_perform(multi_.get(), &running_handles); + int messages_left = 0; + CURLMsg* message = nullptr; + while ((message = curl_multi_info_read(multi_.get(), &messages_left)) != + nullptr) { + if (message->msg == CURLMSG_DONE && message->easy_handle == easy_.get()) { + OnHandshakeDone(message->data.result); + } + } - logger->LogDebug("Error occurred while establishing connection to %s", - client->uri_.c_str()); + // libcurl bounds only DNS/TCP/TLS for a connect-only handle, not the HTTP + // Upgrade exchange, so enforce Connect()'s timeout here. + int timeout_ms = kPollTimeoutMs; + if (state_ == kStateConnecting && + handshake_deadline_ != std::chrono::steady_clock::time_point::max()) { + const auto remaining_ms = + std::chrono::duration_cast( + handshake_deadline_ - std::chrono::steady_clock::now()) + .count(); + if (remaining_ms <= 0) { + logger_->LogDebug("WebSocket handshake to %s timed out", uri_.c_str()); + CleanupEasyHandle(); + state_ = kStateClosed; + OnError(); + continue; + } + timeout_ms = static_cast( + std::min(timeout_ms, static_cast(remaining_ms))); + } + + // Sleep until the handshake makes progress, the open socket is ready, + // Post() wakes us, or the timeout elapses. + curl_waitfd wait_fd; + wait_fd.fd = socket_; + wait_fd.events = CURL_WAIT_POLLIN; + if (!send_queue_.empty()) wait_fd.events |= CURL_WAIT_POLLOUT; + wait_fd.revents = 0; + const bool watch_socket = + state_ == kStateOpen && socket_ != CURL_SOCKET_BAD; + int num_fds = 0; + curl_multi_poll(multi_.get(), watch_socket ? &wait_fd : nullptr, + watch_socket ? 1 : 0, timeout_ms, &num_fds); + if (watch_socket && wait_fd.revents != 0) Pump(wait_fd.revents); + } } -void WebSocketClientImpl::OnConnection(ClientWebSocket* ws, - uWS::HttpRequest req) { - assert(ws != nullptr); - assert(ws->getUserData() != nullptr); - WebSocketClientImpl* client = - static_cast(ws->getUserData()); - - // There should be only one connection per client. However, hub_ can have - // multiple connnections at a time, and current implementation does not - // prevent Connect() from being called when another connection is - // establishing. Use assert for now to prevent this from happening. - assert(client->websocket_ == nullptr); - client->websocket_ = ws; - - if (client->handler_) { - client->scheduler_->Schedule(new callback::CallbackValue1( - client->safe_this_, [](ClientRef client_ref) { - ClientRefLock lock(&client_ref); - auto client = lock.GetReference(); - if (client != nullptr && client->handler_ != nullptr) { - client->handler_->OnOpen(); - } - })); +// --------------------------------------------------------------------------- +// Handshake. +// --------------------------------------------------------------------------- + +void WebSocketClientImpl::ConnectOnLoop(int timeout_ms) { + if (state_ == kStateConnecting || state_ == kStateOpen) { + logger_->LogWarning("websocket has already been connected to %s", + uri_.c_str()); + return; + } + + CleanupEasyHandle(); + + UniqueEasy easy(curl_easy_init()); + if (easy == nullptr) { + logger_->LogError("Failed to initialize curl easy handle for %s", + uri_.c_str()); + OnError(); + return; + } + + curl_easy_setopt(easy.get(), CURLOPT_URL, uri_.c_str()); + // Restrict schemes to ws/wss so system-libcurl builds that compile in extra + // protocols (file://, http://, ftp://, ...) cannot open non-WebSocket URIs. + CURLcode proto_result = + curl_easy_setopt(easy.get(), CURLOPT_PROTOCOLS_STR, "ws,wss"); + if (proto_result != CURLE_OK) { + logger_->LogError("Failed to restrict WebSocket protocols for %s: (%d) %s", + uri_.c_str(), static_cast(proto_result), + curl_easy_strerror(proto_result)); + OnError(); + return; + } + // CURLOPT_CONNECT_ONLY = 2L instructs libcurl to perform the HTTP/1.1 + // WebSocket Upgrade handshake and stop after receiving 101 Switching + // Protocols so framing can be driven via curl_ws_recv() and curl_ws_send(). + curl_easy_setopt(easy.get(), CURLOPT_CONNECT_ONLY, 2L); + // Never let libcurl touch process-wide signal handlers from the I/O thread. + curl_easy_setopt(easy.get(), CURLOPT_NOSIGNAL, 1L); + // Handle PING frames explicitly in ReceiveFrames() so standalone PINGs flush + // their PONG response immediately without waiting for another frame. + curl_easy_setopt(easy.get(), CURLOPT_WS_OPTIONS, + static_cast(CURLWS_NOAUTOPONG)); // NOLINT + // Peer/hostname verification, TLS 1.2 minimum, and OS trust store selection + // are centralized in app/rest/curl/tls_policy.h so REST and WebSocket + // connections can never drift apart. + CURLcode tls_result = firebase::rest::curl::ApplyTlsPolicy(easy.get()); + if (tls_result != CURLE_OK) { + logger_->LogError("Failed to apply TLS policy for %s: (%d) %s", + uri_.c_str(), static_cast(tls_result), + curl_easy_strerror(tls_result)); + OnError(); + return; + } + if (timeout_ms > 0) { + // Bounds DNS + TCP + TLS; the HTTP Upgrade exchange is bounded by + // handshake_deadline_ in Run(). + curl_easy_setopt(easy.get(), CURLOPT_CONNECTTIMEOUT_MS, + static_cast(timeout_ms)); // NOLINT + } + + std::string app_check_token; + { + MutexLock lock(app_check_token_mutex_); + app_check_token = app_check_token_; + } + + auto append_header = [this](const char* header) { + curl_slist* next = curl_slist_append(request_headers_.get(), header); + if (next == nullptr) return false; + request_headers_.release(); + request_headers_.reset(next); + return true; + }; + std::string user_agent_header = "User-Agent: " + user_agent_; + if (!append_header(user_agent_header.c_str())) { + OnError(); + return; + } + + const char* api_client = App::GetUserAgent(); + if (api_client != nullptr && api_client[0] != '\0') { + std::string api_client_header = + std::string(app_common::kApiClientHeader) + ": " + api_client; + if (!append_header(api_client_header.c_str())) { + OnError(); + return; + } + } + + if (!app_check_token.empty()) { + std::string app_check_header = "X-Firebase-AppCheck: " + app_check_token; + if (!append_header(app_check_header.c_str())) { + OnError(); + return; + } } - // Just in case of the client is delete right after connection request is sent - // This may have racing condition with the destructor. But since the - // destructor schedule another task, which runs on the same thread to this - // call, in order to close the websocket, it should not be a big issue. - if (client->is_destructing_.load() > 0) { - client->CloseSync(); + curl_easy_setopt(easy.get(), CURLOPT_HTTPHEADER, request_headers_.get()); + + easy_ = std::move(easy); + incoming_message_buffer_.clear(); + incoming_ping_buffer_.clear(); + send_queue_.clear(); + frame_in_progress_ = false; + current_send_offset_ = 0; + + CURLMcode mc = curl_multi_add_handle(multi_.get(), easy_.get()); + if (mc != CURLM_OK) { + logger_->LogError("curl_multi_add_handle failed: %s", + curl_multi_strerror(mc)); + easy_.reset(); + request_headers_.reset(); + OnError(); + return; } + state_ = kStateConnecting; + handshake_deadline_ = timeout_ms > 0 + ? std::chrono::steady_clock::now() + + std::chrono::milliseconds(timeout_ms) + : std::chrono::steady_clock::time_point::max(); } -void WebSocketClientImpl::OnMessage(ClientWebSocket* ws, char* message, - size_t length, uWS::OpCode opCode) { - assert(ws != nullptr); - assert(ws->getUserData() != nullptr); - WebSocketClientImpl* client = - static_cast(ws->getUserData()); - - if (client->handler_) { - std::string message_string(message, length); - client->scheduler_->Schedule( - new callback::CallbackValue2( - client->safe_this_, message_string, - [](ClientRef client_ref, std::string msg) { - ClientRefLock lock(&client_ref); - auto client = lock.GetReference(); - if (client != nullptr && client->handler_ != nullptr) { - client->handler_->OnMessage(msg.c_str()); - } - })); +void WebSocketClientImpl::OnHandshakeDone(CURLcode result) { + // The handle stays attached to multi_: libcurl closes a connect-only + // connection the moment its handle is removed. + if (result != CURLE_OK) { + logger_->LogDebug( + "Error occurred while establishing connection to %s: (%d) %s", + uri_.c_str(), static_cast(result), curl_easy_strerror(result)); + CleanupEasyHandle(); + state_ = kStateClosed; + OnError(); + return; } + + curl_socket_t sockfd = CURL_SOCKET_BAD; + curl_easy_getinfo(easy_.get(), CURLINFO_ACTIVESOCKET, &sockfd); + if (sockfd == CURL_SOCKET_BAD) { + logger_->LogError("No active socket after WebSocket upgrade to %s", + uri_.c_str()); + CleanupEasyHandle(); + state_ = kStateClosed; + OnError(); + return; + } + + socket_ = sockfd; + state_ = kStateOpen; + OnConnection(); + // Drain any WebSocket frames that arrived in the same packet or TLS record + // as the HTTP 101 Switching Protocols response, and flush anything queued. + Pump(CURL_WAIT_POLLIN | CURL_WAIT_POLLOUT); } -void WebSocketClientImpl::OnDisconnection(ClientWebSocket* ws, int code, - char* message, size_t length) { - assert(ws != nullptr); - assert(ws->getUserData() != nullptr); - WebSocketClientImpl* client = - static_cast(ws->getUserData()); - - assert(client->websocket_ != nullptr); - client->websocket_ = nullptr; - - if (client->handler_) { - client->scheduler_->Schedule(new callback::CallbackValue1( - client->safe_this_, [](ClientRef client_ref) { - ClientRefLock lock(&client_ref); - auto client = lock.GetReference(); - if (client != nullptr && client->handler_ != nullptr) { - client->handler_->OnClose(); - } - })); +// --------------------------------------------------------------------------- +// I/O-thread operations. +// --------------------------------------------------------------------------- + +void WebSocketClientImpl::CloseOnLoop() { + if (state_ == kStateOpen && easy_ != nullptr) { + if (!frame_in_progress_) { + size_t sent = 0; + (void)curl_ws_send(easy_.get(), kCloseNormalPayload, + sizeof(kCloseNormalPayload), &sent, 0, CURLWS_CLOSE); + } + CleanupEasyHandle(); + state_ = kStateClosed; + OnDisconnection(); + } else if (state_ == kStateConnecting && easy_ != nullptr) { + CleanupEasyHandle(); + state_ = kStateClosed; } } -void WebSocketClientImpl::ScheduleOnce(Callback cb, int int_value, - const std::string& string_value) { - assert(cb != nullptr); +void WebSocketClientImpl::SendOnLoop(const std::string& msg) { + if (IsWebSocketAvailable()) { + send_queue_.push_back(OutboundFrame{msg, CURLWS_TEXT}); + // If a frame is already partially written the socket is backpressured; + // Run() polls for POLLOUT while send_queue_ is non-empty. + if (!frame_in_progress_) FlushSendQueue(); + } else { + logger_->LogWarning("Cannot send message. websocket is not available"); + } +} - MutexLock lock(callback_queue_mutex_); - callback_queue_.push(CallbackData(cb, this, int_value, string_value)); +void WebSocketClientImpl::TeardownOnLoop() { + if (state_ == kStateOpen && easy_ != nullptr && !frame_in_progress_) { + size_t sent = 0; + (void)curl_ws_send(easy_.get(), kCloseNormalPayload, + sizeof(kCloseNormalPayload), &sent, 0, CURLWS_CLOSE); + } + CleanupEasyHandle(); + state_ = kStateClosed; +} - // Signal the event loop to trigger the async callback. - process_queue_async_->send(); +void WebSocketClientImpl::Pump(int revents) { + if (state_ != kStateOpen) return; + // Only (re)enter curl_ws_send() when the socket signalled writability or + // nothing is mid-frame: a POLLIN wakeup (server PING, data) while a frame is + // backpressured has nothing to flush yet. + if (!frame_in_progress_ || (revents & CURL_WAIT_POLLOUT) != 0) { + FlushSendQueue(); + } + // Receiving is always attempted: curl_ws_recv() is non-blocking and TLS may + // hold already-decrypted frames that the socket no longer reports as + // readable. + if (state_ == kStateOpen) ReceiveFrames(); } -void WebSocketClientImpl::ProcessCallbackQueue(uS::Async* async) { - assert(async); - assert(async->getData()); +void WebSocketClientImpl::FlushSendQueue() { + if (!IsWebSocketAvailable()) { + return; + } - WebSocketClientImpl* client = - static_cast(async->getData()); + CURL* easy = easy_.get(); + while (!send_queue_.empty() && state_ == kStateOpen) { + const OutboundFrame& frame = send_queue_.front(); + const char* data = frame.payload.data() + current_send_offset_; + size_t remaining = frame.payload.size() - current_send_offset_; + size_t sent = 0; + + frame_in_progress_ = true; + CURLcode res = curl_ws_send(easy, data, remaining, &sent, 0, frame.flags); + if (res == CURLE_OK || res == CURLE_AGAIN) { + current_send_offset_ += sent; + if (current_send_offset_ >= frame.payload.size() && res == CURLE_OK) { + send_queue_.pop_front(); + current_send_offset_ = 0; + frame_in_progress_ = false; + } else { + // Socket write buffer is full; wait for CURL_WAIT_POLLOUT. + break; + } + } else { + logger_->LogError("curl_ws_send failed for %s: (%d) %s", uri_.c_str(), + static_cast(res), curl_easy_strerror(res)); + CleanupEasyHandle(); + state_ = kStateClosed; + OnError(); + OnDisconnection(); + break; + } + } +} - MutexLock lock(client->callback_queue_mutex_); - while (!client->callback_queue_.empty()) { - auto& callback_data = client->callback_queue_.front(); - callback_data.callback(callback_data.client, callback_data.int_value, - callback_data.string_value); - client->callback_queue_.pop(); +void WebSocketClientImpl::ReceiveFrames() { + if (!IsWebSocketAvailable()) { + return; } + + CURL* easy = easy_.get(); + char buffer[kRecvBufferSize]; + + while (state_ == kStateOpen) { + size_t rlen = 0; + const struct curl_ws_frame* meta = nullptr; + CURLcode res = curl_ws_recv(easy, buffer, sizeof(buffer), &rlen, &meta); + + if (res == CURLE_AGAIN) { + break; + } + if (res == CURLE_GOT_NOTHING) { + CleanupEasyHandle(); + state_ = kStateClosed; + OnDisconnection(); + break; + } + if (res != CURLE_OK) { + logger_->LogDebug("curl_ws_recv error on %s: (%d) %s", uri_.c_str(), + static_cast(res), curl_easy_strerror(res)); + CleanupEasyHandle(); + state_ = kStateClosed; + OnError(); + OnDisconnection(); + break; + } + + if (meta == nullptr) { + break; + } + + if (meta->flags & CURLWS_CLOSE) { + if (!frame_in_progress_) { + size_t sent = 0; + (void)curl_ws_send(easy, "", 0, &sent, 0, CURLWS_CLOSE); + } + CleanupEasyHandle(); + state_ = kStateClosed; + OnDisconnection(); + break; + } + + const uint64_t frame_remaining = + meta->bytesleft > 0 ? static_cast(meta->bytesleft) : 0; + + if (meta->flags & CURLWS_PING) { + if ((meta->flags & CURLWS_CONT) != 0 || + rlen > kMaxPingPayloadBytes - incoming_ping_buffer_.size() || + frame_remaining > + kMaxPingPayloadBytes - (incoming_ping_buffer_.size() + rlen)) { + logger_->LogError( + "Invalid or oversized WebSocket PING frame from %s; closing", + uri_.c_str()); + CleanupEasyHandle(); + state_ = kStateClosed; + OnError(); + OnDisconnection(); + break; + } + if (rlen > 0) { + incoming_ping_buffer_.append(buffer, rlen); + } + if (meta->bytesleft == 0) { + OutboundFrame pong{std::move(incoming_ping_buffer_), CURLWS_PONG}; + incoming_ping_buffer_.clear(); + if (frame_in_progress_ && !send_queue_.empty()) { + // A frame is already partially buffered/sent in libcurl's sendbuf; + // queue the PONG immediately behind it (coalescing if a PONG is + // already waiting, per RFC 6455 section 5.5.3) so we never splice a + // control frame mid-frame. + if (send_queue_.size() > 1 && send_queue_[1].flags == CURLWS_PONG) { + send_queue_[1].payload = std::move(pong.payload); + } else { + send_queue_.insert(send_queue_.begin() + 1, std::move(pong)); + } + } else { + send_queue_.push_front(std::move(pong)); + FlushSendQueue(); + } + } + continue; + } + + if (meta->flags & CURLWS_PONG) { + continue; + } + + if (meta->flags & (CURLWS_TEXT | CURLWS_BINARY | CURLWS_CONT)) { + if (rlen > kMaxMessagePayloadBytes - incoming_message_buffer_.size() || + frame_remaining > kMaxMessagePayloadBytes - + (incoming_message_buffer_.size() + rlen)) { + logger_->LogError( + "Inbound WebSocket message from %s exceeds %zu-byte limit; closing", + uri_.c_str(), kMaxMessagePayloadBytes); + CleanupEasyHandle(); + state_ = kStateClosed; + OnError(); + OnDisconnection(); + break; + } + if (rlen > 0) { + incoming_message_buffer_.append(buffer, rlen); + } + if (meta->bytesleft == 0 && (meta->flags & CURLWS_CONT) == 0) { + std::string completed_message; + completed_message.swap(incoming_message_buffer_); + OnMessage(completed_message.data(), completed_message.size()); + } + } + } +} + +void WebSocketClientImpl::CleanupEasyHandle() { + socket_ = CURL_SOCKET_BAD; + if (easy_ != nullptr) { + // Detaching aborts an in-flight handshake or closes the open connection. + curl_multi_remove_handle(multi_.get(), easy_.get()); + easy_.reset(); + } + request_headers_.reset(); + incoming_message_buffer_.clear(); + incoming_ping_buffer_.clear(); + send_queue_.clear(); + frame_in_progress_ = false; + current_send_offset_ = 0; } bool WebSocketClientImpl::IsWebSocketAvailable() const { - return websocket_ != nullptr && !websocket_->isClosed() && - !websocket_->isShuttingDown(); + return state_ == kStateOpen && easy_ != nullptr; +} + +// --------------------------------------------------------------------------- +// Handler notifications, marshalled to the Database scheduler. +// --------------------------------------------------------------------------- + +void WebSocketClientImpl::ScheduleHandlerEvent( + std::function fn) { + if (handler_ == nullptr) return; + ClientRef client_ref = safe_this_; + scheduler_->Schedule([client_ref, fn = std::move(fn)]() mutable { + ClientRefLock lock(&client_ref); + WebSocketClientImpl* client = lock.GetReference(); + if (client != nullptr && client->handler_ != nullptr) { + fn(client->handler_); + } + }); +} + +void WebSocketClientImpl::OnError() { + logger_->LogDebug("Error occurred while establishing connection to %s", + uri_.c_str()); + std::string uri = uri_; + ScheduleHandlerEvent( + [uri = std::move(uri)](WebSocketClientEventHandler* handler) { + WebSocketClientErrorData error(uri.c_str()); + handler->OnError(error); + }); +} + +void WebSocketClientImpl::OnConnection() { + ScheduleHandlerEvent( + [](WebSocketClientEventHandler* handler) { handler->OnOpen(); }); +} + +void WebSocketClientImpl::OnMessage(const char* message, size_t length) { + std::string payload(message, length); + ScheduleHandlerEvent( + [payload = std::move(payload)](WebSocketClientEventHandler* handler) { + handler->OnMessage(payload.c_str()); + }); +} + +void WebSocketClientImpl::OnDisconnection() { + ScheduleHandlerEvent( + [](WebSocketClientEventHandler* handler) { handler->OnClose(); }); } } // namespace connection diff --git a/database/src/desktop/connection/web_socket_client_impl.h b/database/src/desktop/connection/web_socket_client_impl.h index fecd1863c8..33746801bf 100644 --- a/database/src/desktop/connection/web_socket_client_impl.h +++ b/database/src/desktop/connection/web_socket_client_impl.h @@ -1,38 +1,63 @@ -// Copyright 2018 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. +/* + * Copyright 2017 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ #ifndef FIREBASE_DATABASE_SRC_DESKTOP_CONNECTION_WEB_SOCKET_CLIENT_IMPL_H_ #define FIREBASE_DATABASE_SRC_DESKTOP_CONNECTION_WEB_SOCKET_CLIENT_IMPL_H_ -#include +#include // NOLINT +#include +#include +#include +#include #include -#include #include +#include "app/rest/curl/curl_handles.h" #include "app/src/include/firebase/internal/mutex.h" #include "app/src/logger.h" #include "app/src/safe_reference.h" #include "app/src/scheduler.h" #include "app/src/thread.h" +#include "curl/curl.h" #include "database/src/desktop/connection/web_socket_client_interface.h" -#include "uWebSockets/src/uWS.h" namespace firebase { namespace database { namespace internal { namespace connection { +// libcurl-based WebSocket client. +// +// Threading model +// --------------- +// * Each client owns one I/O thread for the lifetime of the object. That +// thread drives a private CURLM multi handle through the HTTP Upgrade +// handshake (CURLOPT_CONNECT_ONLY = 2) and then services the connected +// socket with curl_ws_recv()/curl_ws_send(), sleeping in curl_multi_poll() +// between events. Nothing is shared with the REST transport except +// libcurl's global state (AcquireCurlGlobal()) and the TLS policy. +// * Connect(), Close(), Send() and the destructor may be called from any +// thread (in practice the Database scheduler thread). They post work to the +// I/O thread and return immediately; the destructor posts a teardown and +// joins the thread, so no I/O-thread work touches this object after it +// returns. +// * WebSocketClientEventHandler callbacks (OnOpen/OnMessage/OnClose/OnError) +// are never invoked on the I/O thread: they are marshalled to the Database +// `scheduler_` through `safe_this_`, which also makes them no-ops after the +// client is destroyed. class WebSocketClientImpl : public WebSocketClientInterface { public: WebSocketClientImpl(const std::string& uri, const std::string& user_agent, @@ -58,107 +83,125 @@ class WebSocketClientImpl : public WebSocketClientInterface { void RefreshAppCheckToken(const std::string& token); private: - typedef uWS::WebSocket ClientWebSocket; - typedef void (*Callback)(WebSocketClientImpl* client, int int_value, - const std::string& string_value); - - // Callback for hub_ when connection error occurs - static void OnError(void* data); - - // Callback for hub_ when connection is established - static void OnConnection(ClientWebSocket* ws, uWS::HttpRequest req); - - // Callback for hub_ when a message is received from the server - static void OnMessage(ClientWebSocket* ws, char* message, size_t length, - uWS::OpCode opCode); - - // Callback for hub_ when the connection is closed - static void OnDisconnection(ClientWebSocket* ws, int code, char* message, - size_t length); - - // The thread routine to host the event loop of hub_ - static void EventLoopRoutine(void* data); - - // Synchronously request to close the websocket. Should only be called in - // evnet loop thread. - void CloseSync(); - - // Schedule an async callback to be trigger in the next iteration of the event - // loop. This call is thread-safe and is to prevent multiple threads fighting - // for the same resource, such as websocket_ - void ScheduleOnce(Callback cb, int int_value, - const std::string& string_value); - - // Process callback queue in event loop thread - static void ProcessCallbackQueue(uS::Async* async); + enum State { + kStateIdle = 0, + kStateConnecting, + kStateOpen, + kStateClosed, + }; - // Check if the websocket is available and not closed. - // Only call this in event loop. + // Queues fn to run on the I/O thread and wakes it. Thread-safe. + void Post(std::function fn); + + // The I/O thread. + static void ThreadMain(void* client); + void Run(); + // Runs every queued function. I/O thread only. + void RunPosted(); + + // The following run on the I/O thread only. + + // Start the asynchronous WebSocket handshake. + void ConnectOnLoop(int timeout_ms); + // Handshake finished (CURLMSG_DONE): either fail or switch to kStateOpen. + void OnHandshakeDone(CURLcode result); + // Gracefully close the WebSocket connection and notify the handler. + void CloseOnLoop(); + // Queue and flush an outbound text message. + void SendOnLoop(const std::string& msg); + // Destructor path: release every I/O-thread resource without callbacks. + void TeardownOnLoop(); + + // Socket readiness while open (`revents` is a CURL_WAIT_* mask): flush and + // receive. + void Pump(int revents); + // Attempt to flush any queued outbound messages via curl_ws_send(). + void FlushSendQueue(); + // Drain all available incoming WebSocket frames via curl_ws_recv(). + void ReceiveFrames(); + // Detach and destroy the easy handle and reset all per-connection buffers. + void CleanupEasyHandle(); + // Check if the WebSocket connection is currently established and open. bool IsWebSocketAvailable() const; - // The server uri to connect to. Should start with "ws://" for non-SSL - // connection or "wss://" for SSL connection. + // Event notifications matching the WebSocketClientEventHandler contract; + // each one marshals to scheduler_ via ScheduleHandlerEvent(). + void ScheduleHandlerEvent( + std::function fn); + void OnError(); + void OnConnection(); + void OnMessage(const char* message, size_t length); + void OnDisconnection(); + + // The server URI to connect to ("ws://..." or "wss://..."). const std::string uri_; // The event handler for connection events. WebSocketClientEventHandler* handler_; - // The thread to host the event loop of hub_ - std::unique_ptr thread_; + // Private multi handle: drives the handshake and provides + // curl_multi_poll()/curl_multi_wakeup() for the connected socket. + firebase::rest::curl::UniqueMulti multi_; - // The access point for uWebSockets which contains event loops and - // different sockets. - uWS::Hub hub_; + // Guards posted_. + Mutex post_mutex_; + std::deque> posted_; - // The handler to keep the event loop of hub_ alive even there is no - // connection at all. Otherwise the loop would stop when there is nothing to - // handle anymore. Also used to asynchronously close all async handles and - // WebSocket connection in destructor. - uS::Async* keep_loop_alive_; + // I/O thread only from here on (except where noted). - // Async handler to process callback queue in event look thread. - uS::Async* process_queue_async_; + // Set by the destructor's posted teardown to end Run(). + bool quit_; - // A blob of callback data which is added to callback queue and triggered in - // the event loop thread. The callback member is called with a reference to - // the client, int_value and string_value stored in this data structure. - struct CallbackData { - explicit CallbackData(Callback c, WebSocketClientImpl* ws_client, int i, - const std::string& str) - : callback(c), client(ws_client), int_value(i), string_value(str) {} + // The active libcurl easy handle for the WebSocket connection. Attached to + // multi_ from ConnectOnLoop() until CleanupEasyHandle(): libcurl closes a + // connect-only connection as soon as its handle is removed. + firebase::rest::curl::UniqueEasy easy_; - Callback callback; + // HTTP Upgrade request headers associated with easy_. + firebase::rest::curl::UniqueSList request_headers_; - WebSocketClientImpl* client; + // The connected socket while open, otherwise CURL_SOCKET_BAD. + curl_socket_t socket_; - // Generic purposed int value. Used as timeout in this case. - int int_value; + // Current connection state. + State state_; - // Generic purposed string value. Used as message to send in this case. - std::string string_value; + // Deadline for the HTTP Upgrade exchange. libcurl bounds only DNS/TCP/TLS + // for a CURLOPT_CONNECT_ONLY handle, so Run() enforces this itself. + std::chrono::steady_clock::time_point handshake_deadline_; + + // Buffer for reassembling multi-chunk or fragmented (CURLWS_CONT) incoming + // WebSocket data frames. + std::string incoming_message_buffer_; + + struct OutboundFrame { + std::string payload; + unsigned int flags; }; - // A queue of callback to be triggered in event loop thread. - std::queue callback_queue_; + // Buffer for reassembling incoming PING payloads if split across chunks. + std::string incoming_ping_buffer_; - // Mutex to guard callback_queue_ - Mutex callback_queue_mutex_; + // Queue of outbound WebSocket frames (CURLWS_TEXT data frames and + // CURLWS_PONG control replies) waiting to be sent over the socket. + std::deque send_queue_; - // Flagged when this object starts to be destructed. This helps the other - // thread to handle situation accordingly, ex. if the connection is - // established after this object starts to be deleted. Note that this flag is - // is not guarded by any synchronization primitives and is changed outside of - // the event loop. If referenced during the event loop, make sure the racing - // condition will not cause the problem. - std::atomic is_destructing_; + // True once curl_ws_send() has been invoked for send_queue_.front() and + // libcurl may hold encoded header/payload bytes in its internal sendbuf, + // even when curl_ws_send() returned CURLE_AGAIN with sent == 0. + bool frame_in_progress_; - // The websocket handler to send message, to receive message and to close the - // connection. Should only be used in the event loop. Not thread safe - ClientWebSocket* websocket_; + // Byte offset into send_queue_.front().payload for partial non-blocking + // writes. + size_t current_send_offset_; // User agent used when opening the connection. std::string user_agent_; + // Mutex guarding app_check_token_ (written from the scheduler thread, read + // on the I/O thread when connecting). + Mutex app_check_token_mutex_; + // App Check token used when opening the connection. std::string app_check_token_; @@ -166,14 +209,16 @@ class WebSocketClientImpl : public WebSocketClientInterface { scheduler::Scheduler* scheduler_; - // Safe reference to this. Set in constructor and cleared in destructor - // Should be safe to be copied in any thread because the std::shared_ptr never - // changes, until safe_this_ is completely destroyed. + // Safe reference to this. Set in constructor and cleared in destructor. typedef firebase::internal::SafeReference ClientRef; typedef firebase::internal::SafeReferenceLock ClientRefLock; ClientRef safe_this_; + // Declared last so it is the first member destroyed; it is joined in the + // destructor body before that anyway. + Thread thread_; + friend class PersistentConnectionTest; }; diff --git a/database/src/desktop/database_desktop.cc b/database/src/desktop/database_desktop.cc index a7e0a5e544..459886e1a8 100644 --- a/database/src/desktop/database_desktop.cc +++ b/database/src/desktop/database_desktop.cc @@ -74,6 +74,7 @@ DatabaseInternal::DatabaseInternal(App* app, const char* url) cleanup_(), database_url_(url), constructor_url_(url), + persistence_enabled_(false), logger_(app_common::FindAppLoggerByName(app->name())), repo_(nullptr) { assert(app); diff --git a/database/src/desktop/database_desktop.h b/database/src/desktop/database_desktop.h index 8cd3d24388..6dc991111d 100644 --- a/database/src/desktop/database_desktop.h +++ b/database/src/desktop/database_desktop.h @@ -177,7 +177,7 @@ class DatabaseInternal { // We keep it so that we can find the database in our cache. std::string constructor_url_; - bool persistence_enabled_; + bool persistence_enabled_ = false; // The logger for this instance of the database. Logger logger_; diff --git a/database/tests/CMakeLists.txt b/database/tests/CMakeLists.txt index 4db43fd9a2..7b558016cb 100644 --- a/database/tests/CMakeLists.txt +++ b/database/tests/CMakeLists.txt @@ -342,19 +342,25 @@ firebase_cpp_cc_test( SOURCES desktop/connection/web_socket_client_impl_test.cc INCLUDES + ${CURL_SOURCE_DIR}/include ${OPENSSL_INCLUDE_DIR} - ${UWEBSOCKETS_SOURCE_DIR}/.. DEPENDS firebase_database + firebase_rest_lib firebase_testing + libcurl ${OPENSSL_CRYPTO_LIBRARY} - libuWS ) if(MSVC) target_compile_definitions(firebase_rtdb_desktop_connection_web_socket_client_impl_test PRIVATE -DWIN32_LEAN_AND_MEAN + -DCURL_STATICLIB=1 + ) + target_link_libraries(firebase_rtdb_desktop_connection_web_socket_client_impl_test + PRIVATE + ws2_32 ) endif() @@ -364,10 +370,8 @@ firebase_cpp_cc_test( desktop/connection/connection_test.cc INCLUDES ${OPENSSL_INCLUDE_DIR} - ${UWEBSOCKETS_SOURCE_DIR}/.. DEPENDS ${OPENSSL_CRYPTO_DIR} - libuWS firebase_app_for_testing firebase_database firebase_testing diff --git a/database/tests/desktop/connection/web_socket_client_impl_test.cc b/database/tests/desktop/connection/web_socket_client_impl_test.cc index c0f219b410..0c6405a35e 100644 --- a/database/tests/desktop/connection/web_socket_client_impl_test.cc +++ b/database/tests/desktop/connection/web_socket_client_impl_test.cc @@ -14,104 +14,159 @@ #include "database/src/desktop/connection/web_socket_client_impl.h" +#include +#include // NOLINT +#include +#include +#include +#include // NOLINT +#include +#include +#include // NOLINT +#include + +#include "app/rest/curl/curl_handles.h" +#include "app/rest/request.h" +#include "app/rest/response.h" +#include "app/rest/tests/test_http_server.h" +#include "app/rest/transport_curl.h" +#include "app/src/include/firebase/internal/platform.h" #include "app/src/semaphore.h" +#include "app/src/time.h" +#include "database/src/desktop/util_desktop.h" #include "gmock/gmock.h" #include "gtest/gtest.h" +#if defined(__linux__) +#include +#endif + +#if FIREBASE_PLATFORM_WINDOWS +#include +#include +#else +#include +#include +#include +#include +#include +#include +#endif + namespace firebase { namespace database { namespace internal { namespace connection { -// Simple WebSocket based Echo Server using third_party/uWebSockets -// It has some quirk. Ex. hub_ needs a handler (async_) to wake the loop before -// closing it or the event loop will never stop. +namespace { + +#if FIREBASE_PLATFORM_WINDOWS +typedef SOCKET SocketHandle; +constexpr SocketHandle kInvalidSocket = INVALID_SOCKET; +void CloseSocket(SocketHandle s) { closesocket(s); } +#else +typedef int SocketHandle; +constexpr SocketHandle kInvalidSocket = -1; +void CloseSocket(SocketHandle s) { close(s); } +#endif + +// RFC 6455 magic GUID concatenated with Sec-WebSocket-Key before SHA-1 hashing. +constexpr char kWebSocketGuid[] = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; + +int64_t ElapsedMs(std::chrono::steady_clock::time_point start) { + return std::chrono::duration_cast( + std::chrono::steady_clock::now() - start) + .count(); +} + +// Self-contained RFC 6455 localhost WebSocket test server. class TestWebSocketEchoServer { public: - explicit TestWebSocketEchoServer(int port) - : port_(port), run_(false), thread_(nullptr), keep_alive_(nullptr) { - hub_.onMessage([](uWS::WebSocket* ws, char* message, - size_t length, uWS::OpCode opCode) { - // Echo back immediately - ws->send(message, length, opCode); - }); - hub_.onConnection( - [](uWS::WebSocket* ws, uWS::HttpRequest request) { - LogDebug("[Server] Received connection from (%s) %s port: %d", - ws->getAddress().family, ws->getAddress().address, - ws->getAddress().port); - }); - hub_.onDisconnection([](uWS::WebSocket* ws, int code, - char* message, size_t length) { - LogDebug("[Server] Disconnected from (%s) %s port: %d", - ws->getAddress().family, ws->getAddress().address, - ws->getAddress().port); - }); + enum ServerBehavior { + kBehaviorEcho = 0, + kBehaviorSendFragmentedWithInterleavedPing, + kBehaviorServerInitiatedClose, + // Writes the 101 response and a text frame in one send() so both arrive + // in the same TCP segment. + kBehaviorSendTextWithHandshake, + // Sends a PING right after the handshake and then stops reading for a + // while so a large client message cannot be flushed in one go. + kBehaviorPingThenDelayedRead, + // Waits before answering the handshake so clients can be torn down while + // the upgrade is still in flight. + kBehaviorDelayedHandshake, + // Sends a 5-byte initial fragment followed by a continuation header + // declaring 256 MiB (exceeding kMaxMessagePayloadBytes by 5 bytes). + kBehaviorSendOversizedFragmentedMessage, + // Sends a 126-byte PING control frame (exceeding RFC 6455's 125-byte cap). + kBehaviorSendOversizedPingFrame, + // Accepts the TCP connection and reads the Upgrade request but never + // answers it (overloaded load balancer / captive portal shape). + kBehaviorNeverAnswerHandshake, + }; + + static const char* kTextWithHandshakePayload() { return "hello-with-101"; } + static const char* kDelayedReadPingPayload() { return "late-ping"; } + + explicit TestWebSocketEchoServer(int port = 0, + ServerBehavior behavior = kBehaviorEcho) + : port_(port), + behavior_(behavior), + run_(false), + listen_sock_(kInvalidSocket), + thread_(nullptr), + pong_received_(false) { +#if FIREBASE_PLATFORM_WINDOWS + WSADATA wsa_data; + WSAStartup(MAKEWORD(2, 2), &wsa_data); +#else + signal(SIGPIPE, SIG_IGN); +#endif } - ~TestWebSocketEchoServer() { Stop(); } + ~TestWebSocketEchoServer() { + Stop(); +#if FIREBASE_PLATFORM_WINDOWS + WSACleanup(); +#endif + } void Start() { - keep_alive_ = new uS::Async(hub_.getLoop()); - keep_alive_->setData(this); - keep_alive_->start([](uS::Async* async) { - TestWebSocketEchoServer* server = - static_cast(async->getData()); - assert(server != nullptr); - // close ths group in event loop thread - server->hub_.getDefaultGroup().close(); - async->close(); - }); - - run_ = true; - thread_ = new std::thread([this]() { - auto listen = [&](int port) { - if (hub_.listen(port)) { - LogDebug("[Server] Starts to listen to port %d", port); - return true; - } else { - LogDebug("[Server] Cannot listen to port %d", port); - return false; - } - }; - - if (port_ == 0) { - int attempts = 1000; - int port = 0; - bool res = false; - - do { - --attempts; - port = 10000 + (rand() % 55000); // NOLINT - res = listen(port); - } while (run_ == true && res == false && attempts != 0); - - if (res) { - port_ = port; - hub_.run(); // Blocks until done - } else if (attempts == 0) { - LogError("Failed to find free port after 1000 attempts"); - } - } else { - if (listen(port_) == true) { - hub_.run(); // Blocks until done - } else { - LogWarning("[Server] Cannot listen to port %d", port_.load()); - } - } + listen_sock_ = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + ASSERT_NE(listen_sock_, kInvalidSocket); + + int opt = 1; + setsockopt(listen_sock_, SOL_SOCKET, SO_REUSEADDR, + reinterpret_cast(&opt), sizeof(opt)); + + sockaddr_in addr; + std::memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + addr.sin_port = htons(static_cast(port_.load())); - run_ = false; - }); + ASSERT_EQ( + bind(listen_sock_, reinterpret_cast(&addr), sizeof(addr)), + 0); + ASSERT_EQ(listen(listen_sock_, 128), 0); + + sockaddr_in bound_addr; + socklen_t addr_len = sizeof(bound_addr); + ASSERT_EQ(getsockname(listen_sock_, + reinterpret_cast(&bound_addr), &addr_len), + 0); + port_.store(ntohs(bound_addr.sin_port)); + + run_.store(true); + thread_ = new std::thread([this]() { RunAcceptLoop(); }); } void Stop() { - run_ = false; - - if (keep_alive_) { - keep_alive_->send(); - keep_alive_ = nullptr; + run_.store(false); + if (listen_sock_ != kInvalidSocket) { + CloseSocket(listen_sock_); + listen_sock_ = kInvalidSocket; } - if (thread_ != nullptr) { thread_->join(); delete thread_; @@ -120,27 +175,353 @@ class TestWebSocketEchoServer { } int GetPort(bool waitForPort = false) const { - while (waitForPort == true && run_ == true && port_ == 0) { + while (waitForPort && run_.load() && port_.load() == 0) { firebase::internal::Sleep(10); } + return port_.load(); + } - return port_; + std::string GetLastHandshakeRequest() const { + std::lock_guard lock(mutex_); + return last_handshake_request_; + } + + bool pong_received() const { return pong_received_.load(); } + + std::string last_pong_payload() const { + std::lock_guard lock(mutex_); + return last_pong_payload_; } private: + static bool SendAll(SocketHandle sock, const void* data, size_t len) { + const char* ptr = static_cast(data); + int flags = 0; +#ifdef MSG_NOSIGNAL + flags |= MSG_NOSIGNAL; +#endif + while (len > 0) { + auto sent = send(sock, ptr, static_cast(len), flags); + if (sent <= 0) { + return false; + } + ptr += sent; + len -= static_cast(sent); + } + return true; + } + + static bool RecvExact(SocketHandle sock, void* buf, size_t len) { + char* ptr = static_cast(buf); + while (len > 0) { + auto r = recv(sock, ptr, static_cast(len), 0); + if (r <= 0) { + return false; + } + ptr += r; + len -= static_cast(r); + } + return true; + } + + static bool SendFrame(SocketHandle sock, uint8_t first_byte, + const void* payload, size_t len) { + uint8_t header[10]; + size_t header_len = 0; + header[0] = first_byte; + if (len < 126) { + header[1] = static_cast(len); + header_len = 2; + } else if (len <= 0xFFFF) { + header[1] = 126; + header[2] = static_cast((len >> 8) & 0xFF); + header[3] = static_cast(len & 0xFF); + header_len = 4; + } else { + header[1] = 127; + uint64_t l64 = static_cast(len); + for (int i = 0; i < 8; ++i) { + header[2 + i] = static_cast((l64 >> ((7 - i) * 8)) & 0xFF); + } + header_len = 10; + } + if (!SendAll(sock, header, header_len)) { + return false; + } + if (len > 0 && !SendAll(sock, payload, len)) { + return false; + } + return true; + } + + // Sleeps in slices so Stop() stays prompt. + void SleepWhileRunning(int ms) { + for (int waited = 0; waited < ms && run_.load(); waited += 10) { + firebase::internal::Sleep(10); + } + } + + void RunAcceptLoop() { + while (run_.load()) { + SocketHandle lsock = listen_sock_; + if (lsock == kInvalidSocket) { + break; + } + fd_set read_fds; + FD_ZERO(&read_fds); + FD_SET(lsock, &read_fds); + timeval tv; + tv.tv_sec = 0; + tv.tv_usec = 50 * 1000; // 50ms + int sel = + select(static_cast(lsock + 1), &read_fds, nullptr, nullptr, &tv); + if (sel <= 0 || !run_.load()) { + continue; + } + + sockaddr_in client_addr; + socklen_t client_len = sizeof(client_addr); + SocketHandle client_sock = + accept(lsock, reinterpret_cast(&client_addr), &client_len); + if (client_sock == kInvalidSocket) { + continue; + } + HandleClient(client_sock); + CloseSocket(client_sock); + } + } + + void HandleClient(SocketHandle sock) { + // Set a short socket receive timeout so aborted client connections during + // TestEdgeCase2 do not block the server thread. +#if FIREBASE_PLATFORM_WINDOWS + DWORD timeout_ms = 200; + setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, + reinterpret_cast(&timeout_ms), sizeof(timeout_ms)); +#else + timeval tv; + tv.tv_sec = 0; + tv.tv_usec = 200 * 1000; + setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); +#endif + + // Read HTTP Upgrade request headers until "\r\n\r\n". + std::string request; + char ch = 0; + while (request.find("\r\n\r\n") == std::string::npos) { + auto r = recv(sock, &ch, 1, 0); + if (r <= 0) { + return; + } + request.push_back(ch); + if (request.size() > 8192) { + return; + } + } + + { + std::lock_guard lock(mutex_); + last_handshake_request_ = request; + } + + // Extract Sec-WebSocket-Key header (case-insensitive). + std::string ws_key; + std::istringstream stream(request); + std::string line; + while (std::getline(stream, line)) { + if (!line.empty() && line.back() == '\r') { + line.pop_back(); + } + const std::string kPrefix = "Sec-WebSocket-Key:"; + if (line.size() >= kPrefix.size()) { + bool match = true; + for (size_t i = 0; i < kPrefix.size(); ++i) { + char a = line[i]; + char b = kPrefix[i]; + if (a >= 'A' && a <= 'Z') a = static_cast(a - 'A' + 'a'); + if (b >= 'A' && b <= 'Z') b = static_cast(b - 'A' + 'a'); + if (a != b) { + match = false; + break; + } + } + if (match) { + size_t pos = kPrefix.size(); + while (pos < line.size() && (line[pos] == ' ' || line[pos] == '\t')) { + ++pos; + } + ws_key = line.substr(pos); + break; + } + } + } + if (ws_key.empty()) { + return; + } + + std::string accept_key; + GetBase64SHA1(ws_key + kWebSocketGuid, &accept_key); + + std::string response = + "HTTP/1.1 101 Switching Protocols\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + "Sec-WebSocket-Accept: " + + accept_key + "\r\n\r\n"; + if (behavior_ == kBehaviorDelayedHandshake) { + SleepWhileRunning(500); + if (!run_.load()) return; + } + if (behavior_ == kBehaviorNeverAnswerHandshake) { + // Hold the connection open without replying until the server stops. + SleepWhileRunning(30000); + return; + } + if (behavior_ == kBehaviorSendTextWithHandshake) { + // Append an unmasked, unfragmented text frame to the handshake bytes. + const char* payload = kTextWithHandshakePayload(); + response.push_back(static_cast(0x81)); + response.push_back(static_cast(std::strlen(payload))); + response.append(payload); + } + if (!SendAll(sock, response.data(), response.size())) { + return; + } + + if (behavior_ == kBehaviorPingThenDelayedRead) { + const char* payload = kDelayedReadPingPayload(); + SendFrame(sock, 0x89, payload, std::strlen(payload)); + // Leave the client's outbound data unread so its socket buffer fills. + SleepWhileRunning(1000); + if (!run_.load()) return; + } + + if (behavior_ == kBehaviorServerInitiatedClose) { + const uint8_t close_code[2] = {0x03, 0xe8}; // 1000 Normal Closure + SendFrame(sock, 0x88, close_code, sizeof(close_code)); + return; + } + + if (behavior_ == kBehaviorSendFragmentedWithInterleavedPing) { + // Send a 3-fragment text message with a PING control frame interleaved + // between fragment 1 and fragment 2: + // Fragment 1: FIN=0, opcode=0x1 (TEXT), payload="Part1-" + SendFrame(sock, 0x01, "Part1-", 6); + // Interleaved PING: FIN=1, opcode=0x9 (PING), payload="ping-data" + SendFrame(sock, 0x89, "ping-data", 9); + // Fragment 2: FIN=0, opcode=0x0 (CONT), payload="Part2-" + SendFrame(sock, 0x00, "Part2-", 6); + // Fragment 3: FIN=1, opcode=0x0 (CONT), payload="Part3" + SendFrame(sock, 0x80, "Part3", 5); + } + + if (behavior_ == kBehaviorSendOversizedFragmentedMessage) { + // Fragment 1: FIN=0, opcode=0x1 (TEXT), 5 bytes. + SendFrame(sock, 0x01, "hello", 5); + // Fragment 2 header: FIN=1, opcode=0x0 (CONT), 64-bit length = 256 MiB + // (so total message = 256 MiB + 5 > kMaxMessagePayloadBytes), plus 1 byte + // of payload so curl_ws_recv() delivers the frame metadata immediately. + const uint64_t kContinuationLen = 256ULL * 1024 * 1024; + uint8_t header_and_first_byte[11]; + header_and_first_byte[0] = 0x80; + header_and_first_byte[1] = 127; + for (int i = 0; i < 8; ++i) { + header_and_first_byte[2 + i] = + static_cast((kContinuationLen >> ((7 - i) * 8)) & 0xFF); + } + header_and_first_byte[10] = 'x'; + SendAll(sock, header_and_first_byte, sizeof(header_and_first_byte)); + SleepWhileRunning(200); + return; + } + + if (behavior_ == kBehaviorSendOversizedPingFrame) { + const std::string oversized_ping(126, 'p'); + SendFrame(sock, 0x89, oversized_ping.data(), oversized_ping.size()); + SleepWhileRunning(200); + return; + } + + // Read client frames and echo data frames back. + while (run_.load()) { + uint8_t hdr[2]; + if (!RecvExact(sock, hdr, 2)) { + break; + } + uint8_t opcode = hdr[0] & 0x0F; + bool masked = (hdr[1] & 0x80) != 0; + uint64_t payload_len = hdr[1] & 0x7F; + if (payload_len == 126) { + uint8_t ext[2]; + if (!RecvExact(sock, ext, 2)) break; + payload_len = (static_cast(ext[0]) << 8) | ext[1]; + } else if (payload_len == 127) { + uint8_t ext[8]; + if (!RecvExact(sock, ext, 8)) break; + payload_len = 0; + for (int i = 0; i < 8; ++i) { + payload_len = (payload_len << 8) | ext[i]; + } + } + + uint8_t mask_key[4] = {0, 0, 0, 0}; + if (masked && !RecvExact(sock, mask_key, 4)) { + break; + } + + std::vector payload(static_cast(payload_len)); + if (payload_len > 0 && !RecvExact(sock, payload.data(), payload.size())) { + break; + } + if (masked) { + for (size_t i = 0; i < payload.size(); ++i) { + payload[i] ^= mask_key[i & 3]; + } + } + + if (opcode == 0x8) { + // CLOSE frame: echo CLOSE back and exit. + SendFrame(sock, 0x88, payload.data(), payload.size()); + break; + } else if (opcode == 0x9) { + // PING frame: reply with PONG. + SendFrame(sock, 0x8A, payload.data(), payload.size()); + } else if (opcode == 0xA) { + // PONG frame: record payload. + { + std::lock_guard lock(mutex_); + last_pong_payload_.assign( + reinterpret_cast(payload.data()), payload.size()); + } + pong_received_.store(true); + } else { + // Echo the frame back to the client. + if (!SendFrame(sock, hdr[0], payload.data(), payload.size())) { + break; + } + } + } + } + std::atomic port_; - std::atomic run_; // Is the listen thread started and running - uWS::Hub hub_; + ServerBehavior behavior_; + std::atomic run_; + SocketHandle listen_sock_; std::thread* thread_; - uS::Async* keep_alive_; + mutable std::mutex mutex_; + std::string last_handshake_request_; + std::atomic pong_received_; + std::string last_pong_payload_; }; std::string GetLocalHostUri(int port) { std::stringstream ss; - ss << "ws://localhost:" << port; + ss << "ws://127.0.0.1:" << port; return ss.str(); } +} // namespace + class TestClientEventHandler : public WebSocketClientEventHandler { public: explicit TestClientEventHandler(Semaphore* s) @@ -150,7 +531,7 @@ class TestClientEventHandler : public WebSocketClientEventHandler { is_closed_(false), is_error_(false), semaphore_(s) {} - ~TestClientEventHandler() override{}; + ~TestClientEventHandler() override {} void OnOpen() override { is_connected_ = true; @@ -170,6 +551,7 @@ class TestClientEventHandler : public WebSocketClientEventHandler { void OnError(const WebSocketClientErrorData& error_data) override { is_error_ = true; + error_uri_ = error_data.GetUri(); semaphore_->Post(); } @@ -178,15 +560,15 @@ class TestClientEventHandler : public WebSocketClientEventHandler { std::string msg_received_; bool is_closed_ = false; bool is_error_ = false; + std::string error_uri_; private: Semaphore* semaphore_; }; // Test if the client can connect to a local echo server, send a message, -// receive message and close the connection properly. +// receive a message and close the connection properly. TEST(WebSocketClientImpl, Test1) { - // Launch a local echo server TestWebSocketEchoServer server(0); server.Start(); @@ -196,8 +578,8 @@ TEST(WebSocketClientImpl, Test1) { TestClientEventHandler handler(&semaphore); Logger logger(nullptr); scheduler::Scheduler scheduler; - WebSocketClientImpl ws_client(uri.c_str(), "", &logger, &scheduler, "", - &handler); + WebSocketClientImpl ws_client(uri.c_str(), "test-agent/1.0", &logger, + &scheduler, "", &handler); // Connect to local server LogDebug("[Client] Connecting to %s", uri.c_str()); @@ -222,24 +604,22 @@ TEST(WebSocketClientImpl, Test1) { semaphore.Post(); EXPECT_TRUE(handler.is_closed_ && !handler.is_error_); - // Stop the server server.Stop(); } // Test if it is safe to create the client and destroy it immediately. // This is to test if the destructor can properly end the event loop. -// Otherwise, it would block forever and timeout +// Otherwise, it would block forever and timeout. TEST(WebSocketClientImpl, TestEdgeCase1) { Logger logger(nullptr); scheduler::Scheduler scheduler; - WebSocketClientImpl ws_client("ws://localhost", "", &logger, &scheduler, ""); + WebSocketClientImpl ws_client("ws://127.0.0.1", "", &logger, &scheduler, ""); } // Test if it is safe to connect to a server and destroy the client immediately. -// This is to test if the destructor can properly end the event loop -// Otherwise, it would block forever and timeout +// This is to test if the destructor can properly end the event loop. +// Otherwise, it would block forever and timeout. TEST(WebSocketClientImpl, TestEdgeCase2) { - // Launch a local echo server TestWebSocketEchoServer server(0); server.Start(); Logger logger(nullptr); @@ -247,23 +627,569 @@ TEST(WebSocketClientImpl, TestEdgeCase2) { auto uri = GetLocalHostUri(server.GetPort(true)); - int count = 0; - while ((count++) < 10000) { + for (int count = 0; count < 200; ++count) { WebSocketClientImpl* ws_client = new WebSocketClientImpl(uri.c_str(), "", &logger, &scheduler, ""); - - // Connect to local server - LogDebug("[Client][%d] Connecting to %s", count, uri.c_str()); ws_client->Connect(5000); - - // Immediately destroy the client right after connect request delete ws_client; } - // Stop the server server.Stop(); } +// Test large messages (>64KB, requiring multiple curl_ws_recv chunks) and +// multi-frame fragmented messages (CURLWS_CONT) with an interleaved PING. +TEST(WebSocketClientImpl, LargeAndFragmentedMessagesWithInterleavedPing) { + TestWebSocketEchoServer server( + 0, TestWebSocketEchoServer::kBehaviorSendFragmentedWithInterleavedPing); + server.Start(); + + auto uri = GetLocalHostUri(server.GetPort(true)); + + Semaphore semaphore(0); + TestClientEventHandler handler(&semaphore); + Logger logger(nullptr); + scheduler::Scheduler scheduler; + WebSocketClientImpl ws_client(uri.c_str(), "custom-agent/2.0", &logger, + &scheduler, "initial-token", &handler); + ws_client.RefreshAppCheckToken("refreshed-app-check-token"); + + ws_client.Connect(5000); + // Wait for OnOpen() AND the initial fragmented message sent by the server. + EXPECT_TRUE(semaphore.TimedWait(5000)); + EXPECT_TRUE(semaphore.TimedWait(5000)); + EXPECT_TRUE(handler.is_connected_); + EXPECT_FALSE(handler.is_error_); + EXPECT_TRUE(handler.is_msg_received_); + EXPECT_EQ(handler.msg_received_, "Part1-Part2-Part3"); + + // Send a large 100KB payload to exercise multi-chunk curl_ws_recv reassembly + // and flush the client's PONG back to the server. + std::string large_payload(100 * 1024, 'A'); + for (size_t i = 0; i < large_payload.size(); ++i) { + large_payload[i] = static_cast('A' + (i % 26)); + } + handler.is_msg_received_ = false; + handler.msg_received_.clear(); + ws_client.Send(large_payload.c_str()); + EXPECT_TRUE(semaphore.TimedWait(5000)); + EXPECT_TRUE(handler.is_msg_received_); + EXPECT_EQ(handler.msg_received_, large_payload); + + // Verify the server received the PONG matching its interleaved PING. + EXPECT_TRUE(server.pong_received()); + EXPECT_EQ(server.last_pong_payload(), "ping-data"); + + // Verify HTTP Upgrade request headers included User-Agent, X-Firebase-Client, + // and the refreshed X-Firebase-AppCheck token. + std::string handshake = server.GetLastHandshakeRequest(); + EXPECT_NE(handshake.find("User-Agent: custom-agent/2.0\r\n"), + std::string::npos); + EXPECT_NE( + handshake.find("X-Firebase-AppCheck: refreshed-app-check-token\r\n"), + std::string::npos); + + ws_client.Close(); + EXPECT_TRUE(semaphore.TimedWait(5000)); + EXPECT_TRUE(handler.is_closed_); + + server.Stop(); +} + +// Test server-initiated CLOSE frame triggers OnClose() cleanly. +TEST(WebSocketClientImpl, ServerInitiatedClose) { + TestWebSocketEchoServer server( + 0, TestWebSocketEchoServer::kBehaviorServerInitiatedClose); + server.Start(); + + auto uri = GetLocalHostUri(server.GetPort(true)); + + Semaphore semaphore(0); + TestClientEventHandler handler(&semaphore); + Logger logger(nullptr); + scheduler::Scheduler scheduler; + WebSocketClientImpl ws_client(uri.c_str(), "", &logger, &scheduler, "", + &handler); + + ws_client.Connect(5000); + // Expect OnOpen() followed by OnClose(). + EXPECT_TRUE(semaphore.TimedWait(5000)); + EXPECT_TRUE(semaphore.TimedWait(5000)); + EXPECT_TRUE(handler.is_connected_); + EXPECT_TRUE(handler.is_closed_); + EXPECT_FALSE(handler.is_error_); + + server.Stop(); +} + +// Test connection failure triggers OnError(). +TEST(WebSocketClientImpl, ConnectionFailureTriggersOnError) { + // Bind and immediately stop a server to obtain an unused port. + int closed_port = 0; + { + TestWebSocketEchoServer server(0); + server.Start(); + closed_port = server.GetPort(true); + server.Stop(); + } + + auto uri = GetLocalHostUri(closed_port); + Semaphore semaphore(0); + TestClientEventHandler handler(&semaphore); + Logger logger(nullptr); + scheduler::Scheduler scheduler; + WebSocketClientImpl ws_client(uri.c_str(), "", &logger, &scheduler, "", + &handler); + + ws_client.Connect(2000); + EXPECT_TRUE(semaphore.TimedWait(5000)); + EXPECT_TRUE(handler.is_error_); + EXPECT_FALSE(handler.is_connected_); + EXPECT_EQ(handler.error_uri_, uri); +} + +// libcurl bounds only DNS/TCP/TLS for a CURLOPT_CONNECT_ONLY handle; the HTTP +// Upgrade exchange itself has no libcurl-side timeout. The client's own +// watchdog must turn a server that accepts but never answers the Upgrade into +// OnError() within Connect()'s timeout instead of hanging in "connecting". +TEST(WebSocketClientImpl, HandshakeStallTimesOutViaWatchdog) { + TestWebSocketEchoServer server( + 0, TestWebSocketEchoServer::kBehaviorNeverAnswerHandshake); + server.Start(); + + auto uri = GetLocalHostUri(server.GetPort(true)); + Semaphore semaphore(0); + TestClientEventHandler handler(&semaphore); + Logger logger(nullptr); + scheduler::Scheduler scheduler; + WebSocketClientImpl ws_client(uri.c_str(), "", &logger, &scheduler, "", + &handler); + + auto start = std::chrono::steady_clock::now(); + ws_client.Connect(500); + EXPECT_TRUE(semaphore.TimedWait(5000)); + EXPECT_TRUE(handler.is_error_); + EXPECT_FALSE(handler.is_connected_); + EXPECT_EQ(handler.error_uri_, uri); + // Fired by the watchdog (~500 ms), not by a TCP-level failure. + EXPECT_GE(ElapsedMs(start), 400); + EXPECT_LT(ElapsedMs(start), 4000); + // The server did receive the Upgrade request; it just never answered. + EXPECT_FALSE(server.GetLastHandshakeRequest().empty()); + + server.Stop(); +} + +// Non-ws/wss URI schemes (e.g. http://, file://) must be rejected before +// opening a connection even when linked against a multi-protocol system +// libcurl. +TEST(WebSocketClientImpl, RejectsNonWebSocketUriSchemes) { + TestWebSocketEchoServer server(0); + server.Start(); + + Logger logger(nullptr); + scheduler::Scheduler scheduler; + + const std::string http_uri = + "http://127.0.0.1:" + std::to_string(server.GetPort(true)) + "/"; + { + Semaphore semaphore(0); + TestClientEventHandler handler(&semaphore); + WebSocketClientImpl ws_client(http_uri, "", &logger, &scheduler, "", + &handler); + ws_client.Connect(5000); + EXPECT_TRUE(semaphore.TimedWait(5000)); + EXPECT_TRUE(handler.is_error_); + EXPECT_FALSE(handler.is_connected_); + EXPECT_EQ(http_uri, handler.error_uri_); + EXPECT_TRUE(server.GetLastHandshakeRequest().empty()); + } + + const std::string file_uri = "file:///etc/hosts"; + { + Semaphore semaphore(0); + TestClientEventHandler handler(&semaphore); + WebSocketClientImpl ws_client(file_uri, "", &logger, &scheduler, "", + &handler); + ws_client.Connect(5000); + EXPECT_TRUE(semaphore.TimedWait(5000)); + EXPECT_TRUE(handler.is_error_); + EXPECT_FALSE(handler.is_connected_); + EXPECT_EQ(file_uri, handler.error_uri_); + } + + server.Stop(); +} + +TEST(WebSocketClientImpl, RejectsOversizedInboundMessageAcrossFragments) { + TestWebSocketEchoServer server( + 0, TestWebSocketEchoServer::kBehaviorSendOversizedFragmentedMessage); + server.Start(); + + Semaphore semaphore(0); + TestClientEventHandler handler(&semaphore); + Logger logger(nullptr); + scheduler::Scheduler scheduler; + WebSocketClientImpl ws_client(GetLocalHostUri(server.GetPort(true)), "", + &logger, &scheduler, "", &handler); + + ws_client.Connect(5000); + // Expect OnOpen(), followed by OnError() and OnClose() when the oversized + // continuation header arrives. + EXPECT_TRUE(semaphore.TimedWait(5000)); + EXPECT_TRUE(semaphore.TimedWait(5000)); + EXPECT_TRUE(semaphore.TimedWait(5000)); + EXPECT_TRUE(handler.is_connected_); + EXPECT_FALSE(handler.is_msg_received_); + EXPECT_TRUE(handler.is_error_); + EXPECT_TRUE(handler.is_closed_); + + server.Stop(); +} + +TEST(WebSocketClientImpl, RejectsOversizedInboundPingFrame) { + TestWebSocketEchoServer server( + 0, TestWebSocketEchoServer::kBehaviorSendOversizedPingFrame); + server.Start(); + + Semaphore semaphore(0); + TestClientEventHandler handler(&semaphore); + Logger logger(nullptr); + scheduler::Scheduler scheduler; + WebSocketClientImpl ws_client(GetLocalHostUri(server.GetPort(true)), "", + &logger, &scheduler, "", &handler); + + ws_client.Connect(5000); + // Expect OnOpen(), followed by OnError() and OnClose(). + EXPECT_TRUE(semaphore.TimedWait(5000)); + EXPECT_TRUE(semaphore.TimedWait(5000)); + EXPECT_TRUE(semaphore.TimedWait(5000)); + EXPECT_TRUE(handler.is_connected_); + EXPECT_TRUE(handler.is_error_); + EXPECT_TRUE(handler.is_closed_); + EXPECT_FALSE(server.pong_received()); + + server.Stop(); +} + +// --------------------------------------------------------------------------- +// Interaction with the REST transport and multi-client behaviour. +// --------------------------------------------------------------------------- + +namespace { + +// Holds a REST transport reference for the duration of a test so REST is warm. +class RestLoopScope { + public: + RestLoopScope() { firebase::rest::InitTransportCurl(); } + ~RestLoopScope() { firebase::rest::CleanupTransportCurl(); } +}; + +#if defined(__linux__) +int CountThreadsInProcess() { + int count = 0; + DIR* dir = opendir("/proc/self/task"); + if (dir == nullptr) return -1; + while (readdir(dir) != nullptr) count++; + closedir(dir); + return count - 2; // "." and ".." +} + +// A joined thread can linger in /proc/self/task for a moment after +// pthread_join() returns; poll briefly before reading the count. +int WaitForThreadCount(int expected) { + int count = CountThreadsInProcess(); + for (int i = 0; i < 200 && count != expected; ++i) { + firebase::internal::Sleep(5); + count = CountThreadsInProcess(); + } + return count; +} +#endif // defined(__linux__) + +// A Response that signals a semaphore on completion. +class SignalingResponse : public firebase::rest::Response { + public: + explicit SignalingResponse(Semaphore* semaphore) : semaphore_(semaphore) {} + void MarkCompleted() override { + firebase::rest::Response::MarkCompleted(); + semaphore_->Post(); + } + void MarkFailed() override { + firebase::rest::Response::MarkFailed(); + semaphore_->Post(); + } + + private: + Semaphore* semaphore_; +}; + +} // namespace + +// Two clients to two servers are fully independent; closing one leaves the +// other fully functional. +TEST(WebSocketClientImpl, TwoClientsAreIndependent) { + TestWebSocketEchoServer server_a(0); + TestWebSocketEchoServer server_b(0); + server_a.Start(); + server_b.Start(); + + Semaphore semaphore_a(0); + Semaphore semaphore_b(0); + TestClientEventHandler handler_a(&semaphore_a); + TestClientEventHandler handler_b(&semaphore_b); + Logger logger(nullptr); + scheduler::Scheduler scheduler; + WebSocketClientImpl client_a(GetLocalHostUri(server_a.GetPort(true)), "a", + &logger, &scheduler, "", &handler_a); + WebSocketClientImpl client_b(GetLocalHostUri(server_b.GetPort(true)), "b", + &logger, &scheduler, "", &handler_b); + + client_a.Connect(5000); + client_b.Connect(5000); + EXPECT_TRUE(semaphore_a.TimedWait(5000)); + EXPECT_TRUE(semaphore_b.TimedWait(5000)); + EXPECT_TRUE(handler_a.is_connected_); + EXPECT_TRUE(handler_b.is_connected_); + + client_a.Send("from-a"); + client_b.Send("from-b"); + EXPECT_TRUE(semaphore_a.TimedWait(5000)); + EXPECT_TRUE(semaphore_b.TimedWait(5000)); + EXPECT_EQ("from-a", handler_a.msg_received_); + EXPECT_EQ("from-b", handler_b.msg_received_); + + client_a.Close(); + EXPECT_TRUE(semaphore_a.TimedWait(5000)); + EXPECT_TRUE(handler_a.is_closed_); + + // B is unaffected. + handler_b.is_msg_received_ = false; + client_b.Send("still-alive"); + EXPECT_TRUE(semaphore_b.TimedWait(5000)); + EXPECT_TRUE(handler_b.is_msg_received_); + EXPECT_EQ("still-alive", handler_b.msg_received_); + EXPECT_FALSE(handler_b.is_closed_); + EXPECT_FALSE(handler_a.is_error_); + EXPECT_FALSE(handler_b.is_error_); + + client_b.Close(); + EXPECT_TRUE(semaphore_b.TimedWait(5000)); + EXPECT_TRUE(handler_b.is_closed_); + + server_a.Stop(); + server_b.Stop(); +} + +// A slow REST transfer must not delay WebSocket traffic, and an open WebSocket +// must not delay REST traffic. +TEST(WebSocketClientImpl, RestTransportAndWebSocketDoNotBlockEachOther) { + RestLoopScope scope; + TestWebSocketEchoServer ws_server(0); + ws_server.Start(); + firebase::rest::test::TestHttpServer http_server; + ASSERT_TRUE(http_server.Start()); + + Semaphore ws_semaphore(0); + TestClientEventHandler handler(&ws_semaphore); + Logger logger(nullptr); + scheduler::Scheduler scheduler; + WebSocketClientImpl client(GetLocalHostUri(ws_server.GetPort(true)), "", + &logger, &scheduler, "", &handler); + client.Connect(5000); + EXPECT_TRUE(ws_semaphore.TimedWait(5000)); + ASSERT_TRUE(handler.is_connected_); + + // Start a REST request that stays in flight for 2 s. + Semaphore slow_done(0); + firebase::rest::Request slow_request; + slow_request.set_url((http_server.BaseUrl() + "/slow?ms=2000").c_str()); + SignalingResponse slow_response(&slow_done); + firebase::rest::TransportCurl slow_transport; + slow_transport.set_is_async(true); + slow_transport.Perform(slow_request, &slow_response); + + // WebSocket round trips are not held up by it. + for (int i = 0; i < 5; ++i) { + handler.is_msg_received_ = false; + auto start = std::chrono::steady_clock::now(); + client.Send("ping-while-rest-busy"); + ASSERT_TRUE(ws_semaphore.TimedWait(5000)); + EXPECT_LT(ElapsedMs(start), 1000) << "round trip " << i; + EXPECT_EQ("ping-while-rest-busy", handler.msg_received_); + } + + // The slow one completes normally. + EXPECT_TRUE(slow_done.TimedWait(10000)); + EXPECT_EQ(200, slow_response.status()); + + // Nor is a quick REST request held up by the open WebSocket. + { + Semaphore fast_done(0); + firebase::rest::Request fast_request; + fast_request.set_url((http_server.BaseUrl() + "/").c_str()); + SignalingResponse fast_response(&fast_done); + firebase::rest::TransportCurl fast_transport; + auto start = std::chrono::steady_clock::now(); + fast_transport.Perform(fast_request, &fast_response); // synchronous + EXPECT_LT(ElapsedMs(start), 1000); + EXPECT_EQ(200, fast_response.status()); + EXPECT_STREQ("test", fast_response.GetBody()); + } + + client.Close(); + EXPECT_TRUE(ws_semaphore.TimedWait(5000)); + EXPECT_TRUE(handler.is_closed_); + EXPECT_FALSE(handler.is_error_); + ws_server.Stop(); +} + +// A data frame that arrives in the same TCP segment as the 101 response must +// be delivered. +TEST(WebSocketClientImpl, FrameInSameSegmentAsHandshakeIsDelivered) { + TestWebSocketEchoServer server( + 0, TestWebSocketEchoServer::kBehaviorSendTextWithHandshake); + server.Start(); + + Semaphore semaphore(0); + TestClientEventHandler handler(&semaphore); + Logger logger(nullptr); + scheduler::Scheduler scheduler; + WebSocketClientImpl client(GetLocalHostUri(server.GetPort(true)), "", &logger, + &scheduler, "", &handler); + client.Connect(5000); + // OnOpen followed by OnMessage. + EXPECT_TRUE(semaphore.TimedWait(5000)); + EXPECT_TRUE(semaphore.TimedWait(5000)); + EXPECT_TRUE(handler.is_connected_); + EXPECT_TRUE(handler.is_msg_received_); + EXPECT_EQ(TestWebSocketEchoServer::kTextWithHandshakePayload(), + handler.msg_received_); + EXPECT_FALSE(handler.is_error_); + + client.Close(); + EXPECT_TRUE(semaphore.TimedWait(5000)); + server.Stop(); +} + +// Destroying clients while their handshake is in flight is clean: no +// callbacks, no leaks, and the next client works normally. +TEST(WebSocketClientImpl, DestroyDuringHandshakeIsClean) { + TestWebSocketEchoServer server( + 0, TestWebSocketEchoServer::kBehaviorDelayedHandshake); + server.Start(); + Logger logger(nullptr); + scheduler::Scheduler scheduler; + auto uri = GetLocalHostUri(server.GetPort(true)); + + for (int i = 0; i < 20; ++i) { + Semaphore semaphore(0); + TestClientEventHandler handler(&semaphore); + WebSocketClientImpl* client = + new WebSocketClientImpl(uri, "", &logger, &scheduler, "", &handler); + client->Connect(5000); + if (i % 2 == 1) { + // Let the handshake actually get on the wire before tearing down. + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + } + delete client; + EXPECT_FALSE(handler.is_connected_); + } + + // A fresh client is unaffected by the churn. + TestWebSocketEchoServer echo_server(0); + echo_server.Start(); + Semaphore semaphore(0); + TestClientEventHandler handler(&semaphore); + WebSocketClientImpl client(GetLocalHostUri(echo_server.GetPort(true)), "", + &logger, &scheduler, "", &handler); + client.Connect(5000); + EXPECT_TRUE(semaphore.TimedWait(5000)); + EXPECT_TRUE(handler.is_connected_); + client.Send("after-churn"); + EXPECT_TRUE(semaphore.TimedWait(5000)); + EXPECT_EQ("after-churn", handler.msg_received_); + + server.Stop(); + echo_server.Stop(); +} + +// A PING that arrives while a large outbound frame is only partially written +// (POLLOUT armed) is answered once that frame has been flushed. +TEST(WebSocketClientImpl, PingDuringPartialSendStillGetsPong) { + TestWebSocketEchoServer server( + 0, TestWebSocketEchoServer::kBehaviorPingThenDelayedRead); + server.Start(); + + Semaphore semaphore(0); + TestClientEventHandler handler(&semaphore); + Logger logger(nullptr); + scheduler::Scheduler scheduler; + WebSocketClientImpl client(GetLocalHostUri(server.GetPort(true)), "", &logger, + &scheduler, "", &handler); + client.Connect(5000); + EXPECT_TRUE(semaphore.TimedWait(5000)); + ASSERT_TRUE(handler.is_connected_); + + // Far more than any loopback socket buffer: the server is not reading for + // a second, so curl_ws_send() has to stop part-way and wait for POLLOUT + // while the server's PING is already in our receive path. + std::string large_payload(24 * 1024 * 1024, 'z'); + client.Send(large_payload.c_str()); + EXPECT_TRUE(semaphore.TimedWait(30000)); + EXPECT_TRUE(handler.is_msg_received_); + EXPECT_EQ(large_payload.size(), handler.msg_received_.size()); + EXPECT_EQ(large_payload, handler.msg_received_); + + // The PONG was sent after the frame completed; give the server a moment to + // read it. + for (int i = 0; i < 500 && !server.pong_received(); ++i) { + firebase::internal::Sleep(10); + } + EXPECT_TRUE(server.pong_received()); + EXPECT_EQ(TestWebSocketEchoServer::kDelayedReadPingPayload(), + server.last_pong_payload()); + EXPECT_FALSE(handler.is_error_); + + client.Close(); + EXPECT_TRUE(semaphore.TimedWait(5000)); + server.Stop(); +} + +#if defined(__linux__) +// Each client owns exactly one I/O thread, released when the client is +// destroyed (not merely closed). +TEST(WebSocketClientImpl, ClientOwnsOneThreadReleasedOnDestruction) { + TestWebSocketEchoServer server(0); + server.Start(); + Logger logger(nullptr); + scheduler::Scheduler scheduler; + Semaphore semaphore(0); + TestClientEventHandler handler(&semaphore); + // The scheduler starts its worker thread lazily; warm it up so the baseline + // below already includes it. + scheduler.Schedule([&semaphore]() { semaphore.Post(); }); + ASSERT_TRUE(semaphore.TimedWait(5000)); + + int threads_before = WaitForThreadCount(CountThreadsInProcess()); + ASSERT_GT(threads_before, 0); + { + WebSocketClientImpl client(GetLocalHostUri(server.GetPort(true)), "", + &logger, &scheduler, "", &handler); + EXPECT_EQ(threads_before + 1, WaitForThreadCount(threads_before + 1)); + client.Connect(5000); + EXPECT_TRUE(semaphore.TimedWait(5000)); + client.Send("hi"); + EXPECT_TRUE(semaphore.TimedWait(5000)); + EXPECT_EQ(threads_before + 1, WaitForThreadCount(threads_before + 1)); + client.Close(); + EXPECT_TRUE(semaphore.TimedWait(5000)); + } + EXPECT_EQ(threads_before, WaitForThreadCount(threads_before)); + server.Stop(); +} +#endif // defined(__linux__) + } // namespace connection } // namespace internal } // namespace database diff --git a/release_build_files/NOTICES b/release_build_files/NOTICES index 6b3c23ba45..e15144b92f 100644 --- a/release_build_files/NOTICES +++ b/release_build_files/NOTICES @@ -1354,81 +1354,6 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -===== -libuv -===== -libuv is licensed for use as follows: - -==== -Copyright (c) 2015-present libuv project contributors. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to -deal in the Software without restriction, including without limitation the -rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -sell copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS -IN THE SOFTWARE. -==== - -This license applies to parts of libuv originating from the -https://github.com/joyent/libuv repository: - -==== - -Copyright Joyent, Inc. and other Node contributors. All rights reserved. -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to -deal in the Software without restriction, including without limitation the -rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -sell copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS -IN THE SOFTWARE. - -==== - -This license applies to all parts of libuv that are not externally -maintained libraries. - -The externally maintained libraries used by libuv are: - - - tree.h (from FreeBSD), copyright Niels Provos. Two clause BSD license. - - - inet_pton and inet_ntop implementations, contained in src/inet.c, are - copyright the Internet Systems Consortium, Inc., and licensed under the ISC - license. - - - stdint-msvc2008.h (from msinttypes), copyright Alexander Chemeris. Three - clause BSD license. - - - pthread-fixes.c, copyright Google Inc. and Sony Mobile Communications AB. - Three clause BSD license. - - - android-ifaddrs.h, android-ifaddrs.c, copyright Berkeley Software Design - Inc, Kenneth MacKay and Emergya (Cloud4all, FP7/2007-2013, grant agreement - n° 289016). Three clause BSD license. - - ====== Nanopb ====== @@ -1452,29 +1377,6 @@ redistribute it freely, subject to the following restrictions: 3. This notice may not be removed or altered from any source distribution.a - - - -=========== -uWebSockets -=========== -Copyright (c) 2016 Alex Hultman and contributors - -This software is provided 'as-is', without any express or implied -warranty. In no event will the authors be held liable for any damages -arising from the use of this software. - -Permission is granted to anyone to use this software for any purpose, -including commercial applications, and to alter it and redistribute it -freely, subject to the following restrictions: - -1. The origin of this software must not be misrepresented; you must not - claim that you wrote the original software. If you use this software - in a product, an acknowledgement in the product documentation would be - appreciated but is not required. -2. Altered source versions must be plainly marked as such, and must not be - misrepresented as being the original software. -3. This notice may not be removed or altered from any source distribution. ============================= diff --git a/release_build_files/readme.md b/release_build_files/readme.md index 0bd7234921..e473c26e7d 100644 --- a/release_build_files/readme.md +++ b/release_build_files/readme.md @@ -461,7 +461,7 @@ Authentication | `advapi32, ws2_32, crypt32, bcrypt, iphlpapi, secur32` App Check | `advapi32, ws2_32, crypt32, bcrypt, iphlpapi, secur32` Firestore | `advapi32, ws2_32, crypt32, bcrypt, iphlpapi, secur32, rpcrt4, ole32, shell32, dbghelp` Functions | `advapi32, ws2_32, crypt32, bcrypt, iphlpapi, secur32, rpcrt4, ole32` -Realtime Database | `advapi32, ws2_32, crypt32, bcrypt, iphlpapi, secur32, psapi, userenv, shell32` +Realtime Database | `advapi32, ws2_32, crypt32, bcrypt, iphlpapi, secur32, shell32` Remote Config | `advapi32, ws2_32, crypt32, bcrypt, iphlpapi, secur32, rpcrt4, ole32, icu` Storage | `advapi32, ws2_32, crypt32, bcrypt, iphlpapi, secur32` @@ -618,7 +618,26 @@ code. - General (Desktop): Updated the bundled libcurl from 7.73.0 to 8.23.0. Windows apps linking the desktop libraries must now also link the `bcrypt`, `iphlpapi`, and `secur32` Windows SDK libraries, and macOS apps - must now also link the `SystemConfiguration` framework. + must now also link the `SystemConfiguration` framework. Builds that + supply their own libcurl via `CURL_SOURCE_DIR` require libcurl 8.14.0 or + newer built with WebSocket support. + - General (Desktop): All REST traffic now runs on a single event-driven + libcurl I/O thread (`curl_multi_poll`) instead of a 50 ms polling loop. + Requests are no longer delayed by other in-flight transfers, + `Controller::Cancel()` takes effect immediately, and processes holding + more than 1024 open file descriptors are no longer slowed down. + Asynchronous completion callbacks (for example Storage and Functions + futures) are delivered on a dedicated completion thread and may block + without stalling networking. + - Database (Desktop): Replaced the uWebSockets/libuv WebSocket transport + with libcurl's WebSocket implementation, driven by one lightweight + event-driven thread per connection. Windows apps no longer need to link + `psapi` or `userenv` for the Realtime Database. + - General (Desktop): REST and WebSocket connections now share one TLS + policy: peer and hostname verification are always on, TLS 1.2 is the + minimum version, and certificates are validated against the OS trust + store (Schannel on Windows, the keychain on macOS) or the distribution's + CA bundle on Linux. - General (Linux Desktop): Fixed a potential 1-byte buffer underflow and file descriptor leak when reading `/etc/timezone` in `GetTimezone()`. - Database, Functions, Storage (Android): Fixed a crash in `GetInstance()` diff --git a/remote_config/integration_test/src/integration_test.cc b/remote_config/integration_test/src/integration_test.cc index 2ed2ba7f00..52c5a52e2c 100644 --- a/remote_config/integration_test/src/integration_test.cc +++ b/remote_config/integration_test/src/integration_test.cc @@ -22,6 +22,8 @@ #include #include #include +#include +#include #include "app_framework.h" // NOLINT #include "firebase/app.h" @@ -304,7 +306,7 @@ TEST_F(FirebaseRemoteConfigTest, TestAddOnConfigUpdateListener) { [](firebase::remote_config::ConfigUpdate&&, firebase::remote_config::RemoteConfigError) {}); #else - auto config_update_promise = std::make_shared >(); + auto config_update_promise = std::make_shared>(); auto config_update_future = config_update_promise->get_future(); firebase::remote_config::ConfigUpdateListenerRegistration registration = @@ -519,4 +521,41 @@ TEST_F(FirebaseRemoteConfigTest, TestSetCustomSignals) { "SetCustomSignals")); EXPECT_EQ(rc_->SetCustomSignalsLastResult().error(), 0); } + +// Verifies that multiple Firebase App + RemoteConfig instances can fetch and +// activate concurrently over the shared CurlIoLoop without corrupting state. +TEST_F(FirebaseRemoteConfigTest, TestConcurrentMultiAppFetchAndActivate) { + ASSERT_NE(app_, nullptr); + + const int kNumApps = 6; + std::vector apps(kNumApps, nullptr); + std::vector rcs(kNumApps, nullptr); + std::vector> fetch_futures(kNumApps); + + for (int i = 0; i < kNumApps; ++i) { + std::string name = "concurrent_rc_app_" + std::to_string(i); + apps[i] = firebase::App::Create(app_->options(), name.c_str()); + ASSERT_NE(apps[i], nullptr); + rcs[i] = RemoteConfig::GetInstance(apps[i]); + ASSERT_NE(rcs[i], nullptr); + EXPECT_TRUE(WaitForCompletion(SetDefaults(rcs[i]), "MultiApp SetDefaults")); + fetch_futures[i] = rcs[i]->Fetch(0); + } + + for (int i = 0; i < kNumApps; ++i) { + EXPECT_TRUE(WaitForCompletion(fetch_futures[i], "MultiApp Fetch(0)")); + EXPECT_TRUE(WaitForCompletion(rcs[i]->Activate(), "MultiApp Activate")); + firebase::remote_config::ValueInfo info; + EXPECT_TRUE(rcs[i]->GetBoolean("TestBoolean", &info)); + EXPECT_EQ(info.source, firebase::remote_config::kValueSourceRemoteValue); + EXPECT_EQ(rcs[i]->GetLong("TestLong", &info), 119); + EXPECT_EQ(rcs[i]->GetString("TestString", &info), "This is a string"); + } + + for (int i = 0; i < kNumApps; ++i) { + delete rcs[i]; + delete apps[i]; + } +} + } // namespace firebase_testapp_automated diff --git a/scripts/gha/print_matrix_configuration.py b/scripts/gha/print_matrix_configuration.py index 6384cf3751..c918d481ea 100644 --- a/scripts/gha/print_matrix_configuration.py +++ b/scripts/gha/print_matrix_configuration.py @@ -326,8 +326,6 @@ def filter_values_on_diff(parm_key, value, auto_diff): # ignored completely (set to None), or scan the changes more in-depth (set # to scan_changes function) "cmake/external/firestore.cmake": "firestore", - "cmake/external/libuv.cmake": "database", - "cmake/external/uWebSockets.cmake": "database", "ios_spm/Package.swift":scan_changes_in_file, "Android/firebase_dependencies.gradle":scan_changes_in_file, "release_build_files/Android/firebase_dependencies.gradle":scan_changes_in_file, diff --git a/scripts/git/patches/uWebSockets/0001-fix-want-write-and-sprintf-deprecation.patch b/scripts/git/patches/uWebSockets/0001-fix-want-write-and-sprintf-deprecation.patch deleted file mode 100644 index 793240d324..0000000000 --- a/scripts/git/patches/uWebSockets/0001-fix-want-write-and-sprintf-deprecation.patch +++ /dev/null @@ -1,73 +0,0 @@ -From 300161c1e2e1414fdd8df7ae6f036a6213c3672f Mon Sep 17 00:00:00 2001 -From: "drsanta@google.com" -Date: Thu, 8 Dec 2022 18:52:28 -0500 -Subject: [PATCH] fix want write and sprintf deprecation - ---- - src/HTTPSocket.h | 2 +- - src/Socket.h | 34 ++++++++++++++++++---------------- - 2 files changed, 19 insertions(+), 17 deletions(-) - -diff --git a/src/HTTPSocket.h b/src/HTTPSocket.h -index 5cc7a7f..8b1ab20 100644 ---- a/src/HTTPSocket.h -+++ b/src/HTTPSocket.h -@@ -204,7 +204,7 @@ struct HttpResponse { - - static size_t transform(const char *src, char *dst, size_t length, TransformData transformData) { - // todo: sprintf is extremely slow -- int offset = transformData.hasHead ? 0 : std::sprintf(dst, "HTTP/1.1 200 OK\r\nContent-Length: %u\r\n\r\n", (unsigned int) length); -+ int offset = transformData.hasHead ? 0 : std::snprintf(dst, length, "HTTP/1.1 200 OK\r\nContent-Length: %u\r\n\r\n", (unsigned int) length); - memcpy(dst + offset, src, length); - return length + offset; - } -diff --git a/src/Socket.h b/src/Socket.h -index 2179ff8..521e798 100644 ---- a/src/Socket.h -+++ b/src/Socket.h -@@ -306,24 +306,26 @@ protected: - if (messageQueue.empty()) { - - if (ssl) { -- sent = SSL_write(ssl, message->data, message->length); -- if (sent == (ssize_t) message->length) { -- wasTransferred = false; -- return true; -- } else if (sent < 0) { -- switch (SSL_get_error(ssl, sent)) { -- case SSL_ERROR_WANT_READ: -- break; -- case SSL_ERROR_WANT_WRITE: -- if ((getPoll() & UV_WRITABLE) == 0) { -- setPoll(getPoll() | UV_WRITABLE); -- changePoll(this); -+ /* BEG Patched by firebase-cpp-sdk 00001-fix-want-write-crash.patch */ -+ bool continue_loop = true; -+ do { -+ sent = SSL_write(ssl, message->data, message->length); -+ if (sent == (ssize_t) message->length) { -+ wasTransferred = false; -+ return true; -+ } else if (sent < 0) { -+ switch (SSL_get_error(ssl, sent)) { -+ case SSL_ERROR_WANT_READ: -+ continue_loop = false; -+ break; -+ case SSL_ERROR_WANT_WRITE: -+ break; -+ default: -+ return false; - } -- break; -- default: -- return false; - } -- } -+ } while (continue_loop); -+ /* END Patched by firebase-cpp-sdk 00001-fix-want-write-crash.patch */ - } else { - sent = ::send(getFd(), message->data, message->length, MSG_NOSIGNAL); - if (sent == (ssize_t) message->length) { --- -2.39.0.rc1.256.g54fd8350bd-goog - diff --git a/storage/integration_test/src/integration_test.cc b/storage/integration_test/src/integration_test.cc index 4ced0b91df..6100dd7cfa 100644 --- a/storage/integration_test/src/integration_test.cc +++ b/storage/integration_test/src/integration_test.cc @@ -19,7 +19,9 @@ #include #include #include +#include #include // NOLINT +#include #include "app_framework.h" // NOLINT #include "firebase/app.h" @@ -29,6 +31,13 @@ #include "firebase/util.h" #include "firebase_test_framework.h" // NOLINT +#if FIREBASE_PLATFORM_DESKTOP && !defined(_WIN32) +#include +#include +#include +#include +#endif + // The TO_STRING macro is useful for command line defined strings as the quotes // get stripped. #define TO_STRING_EXPAND(X) #X @@ -1717,4 +1726,228 @@ TEST_F(FirebaseStorageTest, TestInvalidatingReferencesWhenDeletingApp) { InitializeAppAndAuth(); } +// Verifies that Controller::bytes_transferred() and total_byte_count() equal +// the full object size immediately upon download completion (exercising +// CURLOPT_XFERINFOFUNCTION rather than periodic loop sampling). +TEST_F(FirebaseStorageTest, + TestCompletedDownloadReportsExactControllerProgress) { + SignIn(); + + firebase::storage::StorageReference ref = + CreateFolder().Child("TestFile-ExactControllerProgress.bin"); + cleanup_files_.push_back(ref); + + const size_t kFileSize = 2 * 1024 * 1024; + const std::string payload = CreateDataForLargeFile(kFileSize); + + WaitForCompletion( + RunWithRetry([&]() { return ref.PutBytes(payload.data(), kFileSize); }), + "PutBytes"); + + std::vector downloaded(kFileSize, 0); + StorageListener listener; + firebase::storage::Controller controller; + firebase::Future get_future = + ref.GetBytes(downloaded.data(), kFileSize, &listener, &controller); + WaitForCompletion(get_future, "GetBytes"); + ASSERT_NE(get_future.result(), nullptr); + EXPECT_EQ(*get_future.result(), kFileSize); + EXPECT_EQ(memcmp(payload.data(), downloaded.data(), kFileSize), 0); + EXPECT_EQ(controller.total_byte_count(), static_cast(kFileSize)); + EXPECT_EQ(controller.bytes_transferred(), static_cast(kFileSize)); +} + +// Verifies that concurrent small metadata/data RPCs complete promptly while a +// large streaming transfer is active on the shared CurlIoLoop, and that +// Controller::Cancel() wakes the loop immediately. +TEST_F(FirebaseStorageTest, + TestConcurrentOperationsAndFastCancelDuringLargeTransfer) { + SignIn(); + + firebase::storage::StorageReference folder = CreateFolder(); + firebase::storage::StorageReference large_ref = + folder.Child("TestFile-ConcurrentLarge.bin"); + cleanup_files_.push_back(large_ref); + + const size_t kLargeSize = 16 * 1024 * 1024; + const std::string large_payload = CreateDataForLargeFile(kLargeSize); + WaitForCompletion(RunWithRetry([&]() { + return large_ref.PutBytes(large_payload.data(), + kLargeSize); + }), + "PutBytes large"); + + std::vector download_buf(kLargeSize, 0); + StorageListener listener; + firebase::storage::Controller controller; + firebase::Future large_get = large_ref.GetBytes( + download_buf.data(), kLargeSize, &listener, &controller); + + while (!controller.is_valid()) { + ProcessEvents(1); + } + + // Fire 8 concurrent metadata reads while the 16 MB download is streaming. + const int kFastOps = 8; + std::vector> meta_futures( + kFastOps); + for (int i = 0; i < kFastOps; ++i) { + meta_futures[i] = large_ref.GetMetadata(); + } + for (int i = 0; i < kFastOps; ++i) { + WaitForCompletion(meta_futures[i], "Concurrent GetMetadata"); + ASSERT_NE(meta_futures[i].result(), nullptr); + EXPECT_EQ(meta_futures[i].result()->size_bytes(), + static_cast(kLargeSize)); + } + + // If the large download is still in flight, cancelling it must complete + // rapidly via curl_multi_wakeup() rather than hanging. + if (large_get.status() == firebase::kFutureStatusPending) { + const int64_t cancel_start_us = GetCurrentTimeInMicroseconds(); + if (controller.Cancel()) { + WaitForCompletionAnyResult(large_get, "Cancelled large GetBytes"); + const int64_t cancel_elapsed_ms = + (GetCurrentTimeInMicroseconds() - cancel_start_us) / 1000; + // The legacy select() loop only noticed the cancel once the whole 16 MB + // body had arrived (tens of seconds on a slow link); the event loop + // reacts in milliseconds. Keep generous headroom for loaded CI runners. + EXPECT_LT(cancel_elapsed_ms, 5000); + } else { + WaitForCompletionAnyResult(large_get, "Completed large GetBytes"); + } + } else { + EXPECT_EQ(large_get.error(), 0); + } +} + +#if FIREBASE_PLATFORM_DESKTOP && !defined(_WIN32) +// Verifies that Cloud Storage downloads succeed promptly even when the host +// process already holds > FD_SETSIZE (1024) open file descriptors (such as +// mounted .pak archives in a desktop game engine), exercising curl_multi_poll() +// above the legacy select() FD_SETSIZE ceiling. +TEST_F(FirebaseStorageTest, TestDownloadsWithHighOpenFileDescriptors) { + struct rlimit orig_limit {}; + if (getrlimit(RLIMIT_NOFILE, &orig_limit) != 0) { + GTEST_SKIP() << "getrlimit(RLIMIT_NOFILE) failed"; + } + const rlim_t kNeeded = static_cast(FD_SETSIZE + 160); + bool raised_limit = false; + if (orig_limit.rlim_cur < kNeeded && orig_limit.rlim_max >= kNeeded) { + struct rlimit raised = orig_limit; + raised.rlim_cur = + orig_limit.rlim_max < static_cast(FD_SETSIZE + 256) + ? orig_limit.rlim_max + : static_cast(FD_SETSIZE + 256); + if (setrlimit(RLIMIT_NOFILE, &raised) == 0) { + raised_limit = true; + } + } + struct ScopedFdGuard { + std::vector fds; + bool restore_rlimit = false; + struct rlimit saved {}; + ~ScopedFdGuard() { + for (int fd : fds) close(fd); + if (restore_rlimit) setrlimit(RLIMIT_NOFILE, &saved); + } + } guard; + guard.restore_rlimit = raised_limit; + guard.saved = orig_limit; + + struct rlimit active_limit {}; + if (getrlimit(RLIMIT_NOFILE, &active_limit) != 0 || + active_limit.rlim_cur < kNeeded) { + GTEST_SKIP() << "RLIMIT_NOFILE=" << active_limit.rlim_cur << " < " + << kNeeded; + } + + const size_t kFileSize = 1024 * 1024; + const int kConcurrentDownloads = 4; + // Times kConcurrentDownloads parallel downloads of `ref` and checks their + // contents against `payload`. + auto timed_download_burst = [&](firebase::storage::StorageReference ref, + const std::string& payload, + const char* label) -> int64_t { + std::vector> buffers(kConcurrentDownloads, + std::vector(kFileSize, 0)); + std::vector> downloads(kConcurrentDownloads); + const int64_t start_us = GetCurrentTimeInMicroseconds(); + for (int i = 0; i < kConcurrentDownloads; ++i) { + downloads[i] = ref.GetBytes(buffers[i].data(), kFileSize); + } + for (int i = 0; i < kConcurrentDownloads; ++i) { + WaitForCompletion(downloads[i], label); + if (downloads[i].result() == nullptr) continue; + EXPECT_EQ(*downloads[i].result(), kFileSize) << label; + EXPECT_EQ(memcmp(payload.data(), buffers[i].data(), kFileSize), 0) + << label; + } + return (GetCurrentTimeInMicroseconds() - start_us) / 1000; + }; + + // Baseline: the same burst with ordinary (low) file descriptors, measured on + // this very runner so the comparison below is independent of link speed. + SignIn(); + const std::string payload = CreateDataForLargeFile(kFileSize); + const std::string kFileName = "TestFile-HighOpenFds.bin"; + { + firebase::storage::StorageReference baseline_ref = + CreateFolder().Child(kFileName.c_str()); + cleanup_files_.push_back(baseline_ref); + WaitForCompletion(RunWithRetry([&]() { + return baseline_ref.PutBytes(payload.data(), kFileSize); + }), + "PutBytes baseline"); + } + // Fresh App so both measurements pay the same connection/TLS setup cost. + TerminateStorage(); + TerminateAppAndAuth(); + InitializeAppAndAuth(); + InitializeStorage(); + SignIn(); + const int64_t baseline_elapsed_ms = timed_download_burst( + CreateFolder().Child(kFileName.c_str()), payload, "Low-FD GetBytes"); + + // Reserve > FD_SETSIZE descriptors before Auth/Storage open network sockets + // so every TLS socket receives fd >= FD_SETSIZE. + TerminateStorage(); + TerminateAppAndAuth(); + + const int kTargetFd = FD_SETSIZE + 48; + guard.fds.reserve(kTargetFd + 16); + int max_fd = -1; + while (max_fd < kTargetFd) { + int fd = open("/dev/null", O_RDONLY); + if (fd < 0) break; + guard.fds.push_back(fd); + if (fd > max_fd) max_fd = fd; + } + if (max_fd < kTargetFd) { + InitializeAppAndAuth(); + InitializeStorage(); + FAIL() << "Failed to allocate FD >= " << kTargetFd; + } + + InitializeAppAndAuth(); + InitializeStorage(); + SignIn(); + + firebase::storage::StorageReference ref = + CreateFolder().Child(kFileName.c_str()); + cleanup_files_.push_back(ref); + const int64_t high_fd_elapsed_ms = + timed_download_burst(ref, payload, "High-FD GetBytes"); + + // Under the legacy curl_multi_fdset() + select() loop, sockets above + // FD_SETSIZE degraded into 33 ms sleep ticks: ~14x slower than the low-FD + // burst (3,000+ ms vs ~210 ms for 4 x 1 MB on a fast link). Compare against + // the baseline measured on this runner rather than an absolute bound so the + // test is independent of egress bandwidth; the fixed slack absorbs + // per-connection setup jitter. + EXPECT_LT(high_fd_elapsed_ms, 3 * baseline_elapsed_ms + 1000) + << "baseline=" << baseline_elapsed_ms << " ms"; +} +#endif // FIREBASE_PLATFORM_DESKTOP && !defined(_WIN32) + } // namespace firebase_testapp_automated