From f22d157677a3557696f2635909ab5f35994a33ad Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 7 Oct 2025 15:33:51 -0700 Subject: [PATCH 01/90] chore: Cmake updates for FindBoost policies. --- CMakeLists.txt | 8 ++++++++ cmake/launchdarklyConfig.cmake | 7 +++++++ cmake/rfc3339_timestamp.cmake | 2 +- vendor/foxy/CMakeLists.txt | 7 +++++++ 4 files changed, 23 insertions(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 5a899977c..f379e58ca 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -21,6 +21,14 @@ if (POLICY CMP0144) cmake_policy(SET CMP0144 NEW) endif () +# This policy is designed to force a choice between the old behavior of an integrated FindBoost implementation +# and the implementation provided in by boost. +if(POLICY CMP0167) + # Uses the BoostConfig.cmake included with the boost distribution. + cmake_policy(SET CMP0167 NEW) +endif() + + option(BUILD_TESTING "Top-level switch for testing. Turn off to disable unit and contract tests." ON) option(LD_BUILD_SHARED_LIBS "Build the SDKs as shared libraries" OFF) diff --git a/cmake/launchdarklyConfig.cmake b/cmake/launchdarklyConfig.cmake index c0e4779e0..87b0afc76 100644 --- a/cmake/launchdarklyConfig.cmake +++ b/cmake/launchdarklyConfig.cmake @@ -8,6 +8,13 @@ if (NOT DEFINED Boost_USE_STATIC_LIBS) endif () endif () +# This policy is designed to force a choice between the old behavior of an integrated FindBoost implementation +# and the implementation provided in by boost. +if(POLICY CMP0167) + # Uses the BoostConfig.cmake included with the boost distribution. + cmake_policy(SET CMP0167 NEW) +endif() + find_dependency(Boost 1.81 COMPONENTS json url coroutine) find_dependency(OpenSSL) find_dependency(tl-expected) diff --git a/cmake/rfc3339_timestamp.cmake b/cmake/rfc3339_timestamp.cmake index 2416875a2..192c0bfb9 100644 --- a/cmake/rfc3339_timestamp.cmake +++ b/cmake/rfc3339_timestamp.cmake @@ -5,7 +5,7 @@ FetchContent_Declare(timestamp FetchContent_GetProperties(timestamp) if (NOT timestamp_POPULATED) - FetchContent_Populate(timestamp) + FetchContent_MakeAvailable(timestamp) endif () add_library(timestamp OBJECT diff --git a/vendor/foxy/CMakeLists.txt b/vendor/foxy/CMakeLists.txt index a64f767c1..aacca7998 100644 --- a/vendor/foxy/CMakeLists.txt +++ b/vendor/foxy/CMakeLists.txt @@ -11,6 +11,13 @@ cmake_minimum_required(VERSION 3.13) set(foxy_minimum_boost_version 1.75) +# This policy is designed to force a choice between the old behavior of an integrated FindBoost implementation +# and the implementation provided in by boost. +if(POLICY CMP0167) + # Uses the BoostConfig.cmake included with the boost distribution. + cmake_policy(SET CMP0167 NEW) +endif() + project( foxy From 8ef277b37d6da8edd328e098f3a311acbc84b7d5 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 7 Oct 2025 15:58:15 -0700 Subject: [PATCH 02/90] feat: Add networking abstraction non-streaming requests. --- examples/hello-cpp-client/CMakeLists.txt | 5 +++- .../src/data_sources/polling_data_source.hpp | 4 +-- .../events/detail/request_worker.hpp | 4 +-- .../launchdarkly/network/curl_requester.hpp | 22 ++++++++++++++++ .../launchdarkly/network/requester.hpp | 26 +++++++++++++++++++ libs/internal/src/CMakeLists.txt | 4 ++- libs/internal/src/network/curl_requester.cpp | 8 ++++++ 7 files changed, 67 insertions(+), 6 deletions(-) create mode 100644 libs/internal/include/launchdarkly/network/curl_requester.hpp create mode 100644 libs/internal/include/launchdarkly/network/requester.hpp create mode 100644 libs/internal/src/network/curl_requester.cpp diff --git a/examples/hello-cpp-client/CMakeLists.txt b/examples/hello-cpp-client/CMakeLists.txt index c99ab3215..4e47a86bd 100644 --- a/examples/hello-cpp-client/CMakeLists.txt +++ b/examples/hello-cpp-client/CMakeLists.txt @@ -10,6 +10,9 @@ project( set(THREADS_PREFER_PTHREAD_FLAG ON) find_package(Threads REQUIRED) +find_package(CURL) add_executable(hello-cpp-client main.cpp) -target_link_libraries(hello-cpp-client PRIVATE launchdarkly::client Threads::Threads) +target_link_libraries(hello-cpp-client PRIVATE launchdarkly::client Threads::Threads CURL::libcurl) + + diff --git a/libs/client-sdk/src/data_sources/polling_data_source.hpp b/libs/client-sdk/src/data_sources/polling_data_source.hpp index ab30e617b..160a22ab2 100644 --- a/libs/client-sdk/src/data_sources/polling_data_source.hpp +++ b/libs/client-sdk/src/data_sources/polling_data_source.hpp @@ -9,7 +9,7 @@ #include #include #include -#include +#include #include @@ -44,7 +44,7 @@ class PollingDataSource DataSourceEventHandler data_source_handler_; std::string polling_endpoint_; - network::AsioRequester requester_; + network::Requester requester_; Logger const& logger_; boost::asio::any_io_executor ioc_; std::chrono::seconds polling_interval_; diff --git a/libs/internal/include/launchdarkly/events/detail/request_worker.hpp b/libs/internal/include/launchdarkly/events/detail/request_worker.hpp index f2d01da24..02c86204e 100644 --- a/libs/internal/include/launchdarkly/events/detail/request_worker.hpp +++ b/libs/internal/include/launchdarkly/events/detail/request_worker.hpp @@ -6,7 +6,7 @@ #include #include -#include +#include #include #include @@ -159,7 +159,7 @@ class RequestWorker { State state_; /* Component used to perform HTTP operations. */ - network::AsioRequester requester_; + network::Requester requester_; /* Current event batch; only present if AsyncDeliver was called and * request is in-flight or a retry is taking place. */ diff --git a/libs/internal/include/launchdarkly/network/curl_requester.hpp b/libs/internal/include/launchdarkly/network/curl_requester.hpp new file mode 100644 index 000000000..1c1862478 --- /dev/null +++ b/libs/internal/include/launchdarkly/network/curl_requester.hpp @@ -0,0 +1,22 @@ +#pragma once + +#include "http_requester.hpp" +#include "asio_requester.hpp" +#include +#include + + +namespace launchdarkly::network { + +using TlsOptions = config::shared::built::TlsOptions; + +typedef std::function CallbackFunction; +class CurlRequester { + +public: + CurlRequester(net::any_io_executor ctx, TlsOptions const& tls_options); + + void Request(HttpRequest request, std::function cb); +}; + +} // namespace launchdarkly::network diff --git a/libs/internal/include/launchdarkly/network/requester.hpp b/libs/internal/include/launchdarkly/network/requester.hpp new file mode 100644 index 000000000..1ae28738d --- /dev/null +++ b/libs/internal/include/launchdarkly/network/requester.hpp @@ -0,0 +1,26 @@ +#pragma once + +#include "http_requester.hpp" +#include "asio_requester.hpp" +#include +#include + + +namespace launchdarkly::network { + +using TlsOptions = config::shared::built::TlsOptions; + +typedef std::function CallbackFunction; +class Requester { + AsioRequester innerRequester_; +public: + Requester(net::any_io_executor ctx, TlsOptions const& tls_options): innerRequester_(ctx, tls_options) {} + + void Request(HttpRequest request, std::function cb) { + innerRequester_.Request(request, [cb](const HttpResult &res) { + cb(res); + }); + } +}; + +} // namespace launchdarkly::network diff --git a/libs/internal/src/CMakeLists.txt b/libs/internal/src/CMakeLists.txt index 136f6450a..811d7e540 100644 --- a/libs/internal/src/CMakeLists.txt +++ b/libs/internal/src/CMakeLists.txt @@ -56,9 +56,11 @@ target_compile_options(${LIBNAME} PRIVATE message(STATUS "LaunchDarklyInternalSdk_SOURCE_DIR=${LaunchDarklyInternalSdk_SOURCE_DIR}") +find_package(CURL) + target_link_libraries(${LIBNAME} PUBLIC launchdarkly::common - PRIVATE Boost::url Boost::json OpenSSL::SSL Boost::disable_autolinking Boost::headers tl::expected foxy) + PRIVATE Boost::url Boost::json OpenSSL::SSL Boost::disable_autolinking Boost::headers tl::expected foxy CURL::libcurl) # Need the public headers to build. target_include_directories(${LIBNAME} diff --git a/libs/internal/src/network/curl_requester.cpp b/libs/internal/src/network/curl_requester.cpp new file mode 100644 index 000000000..0a06562aa --- /dev/null +++ b/libs/internal/src/network/curl_requester.cpp @@ -0,0 +1,8 @@ +#include "../../include/launchdarkly/network/curl_requester.hpp" +#include "launchdarkly/network/curl_requester.hpp" + +namespace launchdarkly::network { + CurlRequester::CurlRequester(net::any_io_executor ctx, TlsOptions const& tls_options) {} + + void CurlRequester::Request(HttpRequest request, std::function cb) {} +} \ No newline at end of file From 8204d2a07504470e1d87a3d8410a21ee3d76669a Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 8 Oct 2025 08:45:17 -0700 Subject: [PATCH 03/90] Update certify to version including FindBoost updates. --- vendor/foxy/cmake/certify.cmake | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/foxy/cmake/certify.cmake b/vendor/foxy/cmake/certify.cmake index d3af6ea7d..c83933bb7 100644 --- a/vendor/foxy/cmake/certify.cmake +++ b/vendor/foxy/cmake/certify.cmake @@ -10,7 +10,7 @@ endif () FetchContent_Declare(certify GIT_REPOSITORY https://github.com/launchdarkly/certify.git - GIT_TAG 7116dd0e609ae44d037aa562736d3d59fce1b637 + GIT_TAG f8578ace64a2b832e75657cc6fd60bb9260c57ad ) # The tests in certify don't compile. From 6351b59dcad3cdb9268036058c0471ef15999f76 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 8 Oct 2025 09:06:32 -0700 Subject: [PATCH 04/90] Update windows boost version. --- .github/actions/install-openssl/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/install-openssl/action.yml b/.github/actions/install-openssl/action.yml index 544ea40cf..6d10d813f 100644 --- a/.github/actions/install-openssl/action.yml +++ b/.github/actions/install-openssl/action.yml @@ -32,7 +32,7 @@ runs: if: runner.os == 'Windows' shell: bash run: | - choco install openssl --version 3.5.3 -y --no-progress + choco install openssl --version 3.5.4 -y --no-progress if [ -d "C:\Program Files\OpenSSL-Win64" ]; then echo "OPENSSL_ROOT_DIR=C:\Program Files\OpenSSL-Win64" >> $GITHUB_OUTPUT else From f5d3b3136cb681af921cf69ea128ceecf33f4147 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 8 Oct 2025 09:13:32 -0700 Subject: [PATCH 05/90] Try boost 1.85. --- .github/actions/install-boost/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/install-boost/action.yml b/.github/actions/install-boost/action.yml index 848d7ffe8..07e3a450c 100644 --- a/.github/actions/install-boost/action.yml +++ b/.github/actions/install-boost/action.yml @@ -39,7 +39,7 @@ runs: if: runner.os == 'macOS' shell: bash run: | - brew install boost + brew install boost@1.85 echo "BOOST_ROOT=$(brew --prefix boost)" >> $GITHUB_OUTPUT - name: Determine root From 7c2ca08c88433e8f9cebebebd3cdeda460d2b2c0 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 8 Oct 2025 09:25:38 -0700 Subject: [PATCH 06/90] No boost root --- .github/actions/install-boost/action.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/actions/install-boost/action.yml b/.github/actions/install-boost/action.yml index 07e3a450c..57b4f0734 100644 --- a/.github/actions/install-boost/action.yml +++ b/.github/actions/install-boost/action.yml @@ -39,8 +39,8 @@ runs: if: runner.os == 'macOS' shell: bash run: | - brew install boost@1.85 - echo "BOOST_ROOT=$(brew --prefix boost)" >> $GITHUB_OUTPUT + brew install boost +# echo "BOOST_ROOT=$(brew --prefix boost)" >> $GITHUB_OUTPUT - name: Determine root id: determine-root From 069edf1d9922dcec504d37af6dcb34e4376dfee7 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 8 Oct 2025 09:43:06 -0700 Subject: [PATCH 07/90] Try boost 1.85 with link --- .github/actions/install-boost/action.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/actions/install-boost/action.yml b/.github/actions/install-boost/action.yml index 57b4f0734..8880ce77c 100644 --- a/.github/actions/install-boost/action.yml +++ b/.github/actions/install-boost/action.yml @@ -39,7 +39,8 @@ runs: if: runner.os == 'macOS' shell: bash run: | - brew install boost + brew install boost@1.85 + brew link boost@1.85 # echo "BOOST_ROOT=$(brew --prefix boost)" >> $GITHUB_OUTPUT - name: Determine root From 915259913baa6d50bc3aaf00c79d67348f0a680e Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 8 Oct 2025 09:58:52 -0700 Subject: [PATCH 08/90] Update certify to remove boost::system dependency --- .github/actions/install-boost/action.yml | 5 ++--- vendor/foxy/cmake/certify.cmake | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/actions/install-boost/action.yml b/.github/actions/install-boost/action.yml index 8880ce77c..848d7ffe8 100644 --- a/.github/actions/install-boost/action.yml +++ b/.github/actions/install-boost/action.yml @@ -39,9 +39,8 @@ runs: if: runner.os == 'macOS' shell: bash run: | - brew install boost@1.85 - brew link boost@1.85 -# echo "BOOST_ROOT=$(brew --prefix boost)" >> $GITHUB_OUTPUT + brew install boost + echo "BOOST_ROOT=$(brew --prefix boost)" >> $GITHUB_OUTPUT - name: Determine root id: determine-root diff --git a/vendor/foxy/cmake/certify.cmake b/vendor/foxy/cmake/certify.cmake index c83933bb7..3d2d353c7 100644 --- a/vendor/foxy/cmake/certify.cmake +++ b/vendor/foxy/cmake/certify.cmake @@ -10,7 +10,7 @@ endif () FetchContent_Declare(certify GIT_REPOSITORY https://github.com/launchdarkly/certify.git - GIT_TAG f8578ace64a2b832e75657cc6fd60bb9260c57ad + GIT_TAG 71023298ae232ee01cc7c4c80ea19b7b12bfeb19 ) # The tests in certify don't compile. From ac66ddcf96d4b60da9ac6d78122037fc7b81dfae Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 8 Oct 2025 10:07:26 -0700 Subject: [PATCH 09/90] Remove Boost::system from foxy. --- vendor/foxy/CMakeLists.txt | 2 -- 1 file changed, 2 deletions(-) diff --git a/vendor/foxy/CMakeLists.txt b/vendor/foxy/CMakeLists.txt index aacca7998..99b33d564 100644 --- a/vendor/foxy/CMakeLists.txt +++ b/vendor/foxy/CMakeLists.txt @@ -37,7 +37,6 @@ include(${CMAKE_FILES}/certify.cmake) find_package( Boost ${foxy_minimum_boost_version} REQUIRED - system date_time ) @@ -155,7 +154,6 @@ target_link_libraries( PUBLIC Boost::boost - Boost::system Boost::date_time OpenSSL::SSL Threads::Threads From 22b258ddafd5b62e8984b7d669b52676231d6e6a Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 8 Oct 2025 10:51:51 -0700 Subject: [PATCH 10/90] Use prefix to find boost cmake configuration. --- .github/actions/install-boost/action.yml | 1 + .github/workflows/hello-apps.yml | 2 ++ 2 files changed, 3 insertions(+) diff --git a/.github/actions/install-boost/action.yml b/.github/actions/install-boost/action.yml index 848d7ffe8..db3b24ab6 100644 --- a/.github/actions/install-boost/action.yml +++ b/.github/actions/install-boost/action.yml @@ -33,6 +33,7 @@ runs: run: | choco install boost-msvc-14.3 --version 1.87.0 -y --no-progress echo "BOOST_ROOT=C:\local\boost_1_87_0" >> $GITHUB_OUTPUT + echo "CMAKE_PREFIX_PATH=C:\local\boost_1_87_0\lib64-msvc-14.3\cmake\Boost-1.87.0" >> $GITHUB_OUTPUT - name: Install boost using homebrew id: brew-action diff --git a/.github/workflows/hello-apps.yml b/.github/workflows/hello-apps.yml index 4b76149aa..6a13bd53e 100644 --- a/.github/workflows/hello-apps.yml +++ b/.github/workflows/hello-apps.yml @@ -34,7 +34,9 @@ jobs: run: ./scripts/run-hello-apps.sh static hello-c-client hello-cpp-client hello-c-server hello-cpp-server env: BOOST_ROOT: ${{ steps.install-boost.outputs.BOOST_ROOT }} + CMAKE_PREFIX_PATH: ${{ steps.install-boost.outputs.CMAKE_PREFIX_PATH }} OPENSSL_ROOT_DIR: ${{ steps.install-openssl.outputs.OPENSSL_ROOT_DIR }} + - name: Dynamically Linked Hello Apps (C API only) shell: bash continue-on-error: true # TODO(SC-223804) From 94b520c957f34b5cfc0d53d914bb96a6f5f468e7 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 8 Oct 2025 11:07:55 -0700 Subject: [PATCH 11/90] Use Boost_DIR instead of prefix. --- .github/actions/install-boost/action.yml | 2 +- .github/actions/sdk-release/action.yml | 1 + .github/workflows/hello-apps.yml | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/actions/install-boost/action.yml b/.github/actions/install-boost/action.yml index db3b24ab6..d10aa5b33 100644 --- a/.github/actions/install-boost/action.yml +++ b/.github/actions/install-boost/action.yml @@ -33,7 +33,7 @@ runs: run: | choco install boost-msvc-14.3 --version 1.87.0 -y --no-progress echo "BOOST_ROOT=C:\local\boost_1_87_0" >> $GITHUB_OUTPUT - echo "CMAKE_PREFIX_PATH=C:\local\boost_1_87_0\lib64-msvc-14.3\cmake\Boost-1.87.0" >> $GITHUB_OUTPUT + echo "Boost_DIR=C:\local\boost_1_87_0\lib64-msvc-14.3\cmake\Boost-1.87.0" >> $GITHUB_OUTPUT - name: Install boost using homebrew id: brew-action diff --git a/.github/actions/sdk-release/action.yml b/.github/actions/sdk-release/action.yml index b1da9ced4..ab8e17ad2 100644 --- a/.github/actions/sdk-release/action.yml +++ b/.github/actions/sdk-release/action.yml @@ -100,6 +100,7 @@ runs: if: runner.os == 'Windows' shell: bash env: + Boost_DIR: ${{ steps.install-openssl.outputs.Boost_DIR }} BOOST_ROOT: ${{ steps.install-boost.outputs.BOOST_ROOT }} OPENSSL_ROOT_DIR: ${{ steps.install-openssl.outputs.OPENSSL_ROOT_DIR }} run: ./scripts/build-release-windows.sh ${{ inputs.sdk_cmake_target }} diff --git a/.github/workflows/hello-apps.yml b/.github/workflows/hello-apps.yml index 6a13bd53e..586cf26fe 100644 --- a/.github/workflows/hello-apps.yml +++ b/.github/workflows/hello-apps.yml @@ -35,7 +35,7 @@ jobs: env: BOOST_ROOT: ${{ steps.install-boost.outputs.BOOST_ROOT }} CMAKE_PREFIX_PATH: ${{ steps.install-boost.outputs.CMAKE_PREFIX_PATH }} - OPENSSL_ROOT_DIR: ${{ steps.install-openssl.outputs.OPENSSL_ROOT_DIR }} + Boost_DIR: ${{ steps.install-openssl.outputs.Boost_DIR }} - name: Dynamically Linked Hello Apps (C API only) shell: bash From 531c80579cef8720aaeea85a7ccfc68d282e9bdb Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 8 Oct 2025 12:23:38 -0700 Subject: [PATCH 12/90] Use correct step. --- .github/workflows/hello-apps.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/hello-apps.yml b/.github/workflows/hello-apps.yml index 586cf26fe..5dbc9bc00 100644 --- a/.github/workflows/hello-apps.yml +++ b/.github/workflows/hello-apps.yml @@ -35,7 +35,7 @@ jobs: env: BOOST_ROOT: ${{ steps.install-boost.outputs.BOOST_ROOT }} CMAKE_PREFIX_PATH: ${{ steps.install-boost.outputs.CMAKE_PREFIX_PATH }} - Boost_DIR: ${{ steps.install-openssl.outputs.Boost_DIR }} + Boost_DIR: ${{ steps.install-boost.outputs.Boost_DIR }} - name: Dynamically Linked Hello Apps (C API only) shell: bash From df9138892202a542c22350406c093e412122bbc0 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 8 Oct 2025 15:54:01 -0700 Subject: [PATCH 13/90] fix: Ensure that serialization of a variation or rollout uses the correct overload. --- .../include/launchdarkly/serialization/json_flag.hpp | 5 ++++- .../launchdarkly/serialization/value_mapping.hpp | 10 ++++++++++ libs/internal/src/serialization/json_flag.cpp | 4 ++-- libs/internal/tests/data_model_serialization_test.cpp | 7 ++++--- 4 files changed, 20 insertions(+), 6 deletions(-) diff --git a/libs/internal/include/launchdarkly/serialization/json_flag.hpp b/libs/internal/include/launchdarkly/serialization/json_flag.hpp index ec5a5604c..6fa55cb61 100644 --- a/libs/internal/include/launchdarkly/serialization/json_flag.hpp +++ b/libs/internal/include/launchdarkly/serialization/json_flag.hpp @@ -7,6 +7,9 @@ namespace launchdarkly { +struct VariationOrRolloutContext {}; + + tl::expected, JsonError> tag_invoke( boost::json::value_to_tag< tl::expected, @@ -72,7 +75,7 @@ void tag_invoke(boost::json::value_from_tag const& unused, void tag_invoke( boost::json::value_from_tag const& unused, boost::json::value& json_value, - data_model::Flag::VariationOrRollout const& variation_or_rollout); + data_model::Flag::VariationOrRollout const& variation_or_rollout, const VariationOrRolloutContext&); void tag_invoke( boost::json::value_from_tag const& unused, diff --git a/libs/internal/include/launchdarkly/serialization/value_mapping.hpp b/libs/internal/include/launchdarkly/serialization/value_mapping.hpp index 69ad964fe..4894d02e7 100644 --- a/libs/internal/include/launchdarkly/serialization/value_mapping.hpp +++ b/libs/internal/include/launchdarkly/serialization/value_mapping.hpp @@ -189,6 +189,16 @@ void WriteMinimal(boost::json::object& obj, } } +template +void WriteMinimal(boost::json::object& obj, + std::string const& key, + T const& val, + std::function const& predicate, const C &c) { + if (predicate()) { + obj.emplace(key, boost::json::value_from(val, c)); + } +} + void WriteMinimal(boost::json::object& obj, std::string const& key, // No copy when not used. bool val); diff --git a/libs/internal/src/serialization/json_flag.cpp b/libs/internal/src/serialization/json_flag.cpp index 404ee1def..787a01416 100644 --- a/libs/internal/src/serialization/json_flag.cpp +++ b/libs/internal/src/serialization/json_flag.cpp @@ -255,7 +255,7 @@ void tag_invoke(boost::json::value_from_tag const& unused, void tag_invoke( boost::json::value_from_tag const& unused, boost::json::value& json_value, - data_model::Flag::VariationOrRollout const& variation_or_rollout) { + data_model::Flag::VariationOrRollout const& variation_or_rollout, const VariationOrRolloutContext&) { auto& obj = json_value.emplace_object(); std::visit( [&obj](auto&& arg) { @@ -364,7 +364,7 @@ void tag_invoke(boost::json::value_from_tag const& unused, } }, flag.fallthrough); - }); + }, VariationOrRolloutContext()); WriteMinimal(obj, "clientSideAvailability", flag.clientSideAvailability, [&]() { return flag.clientSideAvailability.usingEnvironmentId || diff --git a/libs/internal/tests/data_model_serialization_test.cpp b/libs/internal/tests/data_model_serialization_test.cpp index 709af98f6..e117035a7 100644 --- a/libs/internal/tests/data_model_serialization_test.cpp +++ b/libs/internal/tests/data_model_serialization_test.cpp @@ -423,7 +423,7 @@ TEST(RolloutTests, SerializeAllFields) { TEST(VariationOrRolloutTests, SerializeVariation) { data_model::Flag::VariationOrRollout variation = 5; - auto json = boost::json::value_from(variation); + auto json = boost::json::value_from(variation, VariationOrRolloutContext()); auto expected = boost::json::parse(R"({"variation":5})"); EXPECT_EQ(expected, json); @@ -438,8 +438,9 @@ TEST(VariationOrRolloutTests, SerializeRollout) { rollout.seed = 42; rollout.variations = { data_model::Flag::Rollout::WeightedVariation::Untracked(1, 2), {3, 4}}; - data_model::Flag::VariationOrRollout var_or_roll = rollout; - auto json = boost::json::value_from(var_or_roll); + data_model::Flag::VariationOrRollout var_or_roll; + var_or_roll.emplace(rollout); + auto json = boost::json::value_from(var_or_roll, VariationOrRolloutContext()); auto expected = boost::json::parse(R"({ "rollout":{ From 24b26297e2084213ea1b831490c48f879b7fe60f Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 8 Oct 2025 16:18:27 -0700 Subject: [PATCH 14/90] Add conditional compilation. --- .../launchdarkly/serialization/value_mapping.hpp | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/libs/internal/include/launchdarkly/serialization/value_mapping.hpp b/libs/internal/include/launchdarkly/serialization/value_mapping.hpp index 4894d02e7..2758de0f8 100644 --- a/libs/internal/include/launchdarkly/serialization/value_mapping.hpp +++ b/libs/internal/include/launchdarkly/serialization/value_mapping.hpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include @@ -195,7 +196,16 @@ void WriteMinimal(boost::json::object& obj, T const& val, std::function const& predicate, const C &c) { if (predicate()) { + // In Boost 1.83 the ability to have a conversion context was added. + // It also introduces the potential for the wrong conversion to be used, + // so for boost 1.83 and greater we use a conversion context to ensure + // the correct serialization is used. +#if BOOST_VERSION >= 108300 obj.emplace(key, boost::json::value_from(val, c)); +#else + boost::ignore_unused(c); + obj.emplace(key, boost::json::value_from(val)); +#endif } } From fd3bd447b38737b945802475fbc7a2023fc0a26f Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 8 Oct 2025 16:22:42 -0700 Subject: [PATCH 15/90] Conditional boost code in tests. --- .../tests/data_model_serialization_test.cpp | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/libs/internal/tests/data_model_serialization_test.cpp b/libs/internal/tests/data_model_serialization_test.cpp index e117035a7..66a3efe25 100644 --- a/libs/internal/tests/data_model_serialization_test.cpp +++ b/libs/internal/tests/data_model_serialization_test.cpp @@ -423,8 +423,13 @@ TEST(RolloutTests, SerializeAllFields) { TEST(VariationOrRolloutTests, SerializeVariation) { data_model::Flag::VariationOrRollout variation = 5; - auto json = boost::json::value_from(variation, VariationOrRolloutContext()); + //Explanation in value_mapping.hpp. +#if BOOST_VERSION >= 108300 + auto json = boost::json::value_from(variation, VariationOrRolloutContext()); +#else + auto json = boost::json::value_from(var_or_roll); +#endif auto expected = boost::json::parse(R"({"variation":5})"); EXPECT_EQ(expected, json); } @@ -440,7 +445,13 @@ TEST(VariationOrRolloutTests, SerializeRollout) { data_model::Flag::Rollout::WeightedVariation::Untracked(1, 2), {3, 4}}; data_model::Flag::VariationOrRollout var_or_roll; var_or_roll.emplace(rollout); + //Explanation in value_mapping.hpp. +#if BOOST_VERSION >= 108300 auto json = boost::json::value_from(var_or_roll, VariationOrRolloutContext()); +#else + auto json = boost::json::value_from(var_or_roll); +#endif + auto expected = boost::json::parse(R"({ "rollout":{ From 1ee9e1d9ad8b53b99305bdd920a4fa5a507c3292 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 8 Oct 2025 16:28:28 -0700 Subject: [PATCH 16/90] WIP --- .../launchdarkly/network/curl_requester.hpp | 7 +- libs/internal/src/network/curl_requester.cpp | 178 +++++++++++++++++- .../tests/data_model_serialization_test.cpp | 2 +- 3 files changed, 183 insertions(+), 4 deletions(-) diff --git a/libs/internal/include/launchdarkly/network/curl_requester.hpp b/libs/internal/include/launchdarkly/network/curl_requester.hpp index 1c1862478..10cecbe8c 100644 --- a/libs/internal/include/launchdarkly/network/curl_requester.hpp +++ b/libs/internal/include/launchdarkly/network/curl_requester.hpp @@ -12,11 +12,16 @@ using TlsOptions = config::shared::built::TlsOptions; typedef std::function CallbackFunction; class CurlRequester { - public: CurlRequester(net::any_io_executor ctx, TlsOptions const& tls_options); void Request(HttpRequest request, std::function cb); + +private: + void PerformRequest(HttpRequest request, std::function cb); + + net::any_io_executor ctx_; + TlsOptions tls_options_; }; } // namespace launchdarkly::network diff --git a/libs/internal/src/network/curl_requester.cpp b/libs/internal/src/network/curl_requester.cpp index 0a06562aa..ebab3bdf6 100644 --- a/libs/internal/src/network/curl_requester.cpp +++ b/libs/internal/src/network/curl_requester.cpp @@ -1,8 +1,182 @@ #include "../../include/launchdarkly/network/curl_requester.hpp" #include "launchdarkly/network/curl_requester.hpp" +#include +#include +#include namespace launchdarkly::network { - CurlRequester::CurlRequester(net::any_io_executor ctx, TlsOptions const& tls_options) {} - void CurlRequester::Request(HttpRequest request, std::function cb) {} +// Callback for writing response data +static size_t WriteCallback(void* contents, size_t size, size_t nmemb, void* userp) { + size_t total_size = size * nmemb; + std::string* str = static_cast(userp); + str->append(static_cast(contents), total_size); + return total_size; +} + +// Callback for reading request headers +static size_t HeaderCallback(char* buffer, size_t size, size_t nitems, void* userdata) { + size_t total_size = size * nitems; + auto* headers = static_cast(userdata); + + std::string header(buffer, total_size); + + // Skip status line and empty lines + if (header.find("HTTP/") == 0 || header == "\r\n" || header == "\n") { + return total_size; + } + + // Parse header + size_t colon_pos = header.find(':'); + if (colon_pos != std::string::npos) { + std::string key = header.substr(0, colon_pos); + std::string value = header.substr(colon_pos + 1); + + // Trim whitespace + value.erase(0, value.find_first_not_of(" \t")); + value.erase(value.find_last_not_of(" \t\r\n") + 1); + + headers->insert_or_assign(key, value); + } + + return total_size; +} + +// Convert HttpMethod to CURL method string +static const char* MethodToCurlString(HttpMethod method) { + switch (method) { + case HttpMethod::kGet: + return "GET"; + case HttpMethod::kPost: + return "POST"; + case HttpMethod::kPut: + return "PUT"; + case HttpMethod::kReport: + return "REPORT"; + } + return "GET"; +} + +CurlRequester::CurlRequester(net::any_io_executor ctx, TlsOptions const& tls_options) + : ctx_(std::move(ctx)), tls_options_(tls_options) { + curl_global_init(CURL_GLOBAL_DEFAULT); +} + +void CurlRequester::Request(HttpRequest request, std::function cb) { + // Post the request to the executor to perform it asynchronously + boost::asio::post(ctx_, [this, request = std::move(request), cb = std::move(cb)]() mutable { + PerformRequest(std::move(request), std::move(cb)); + }); +} + +void CurlRequester::PerformRequest(HttpRequest request, std::function cb) { + // Validate request + if (!request.Valid()) { + cb(HttpResult("The request was malformed and could not be made.")); + return; + } + + CURL* curl = curl_easy_init(); + if (!curl) { + cb(HttpResult("Failed to initialize CURL")); + return; + } + + // Use RAII to ensure cleanup + std::unique_ptr curl_guard(curl, curl_easy_cleanup); + + std::string response_body; + HttpResult::HeadersType response_headers; + + // Set URL + curl_easy_setopt(curl, CURLOPT_URL, request.Url().c_str()); + + // Set HTTP method + const char* method_str = MethodToCurlString(request.Method()); + if (request.Method() == HttpMethod::kPost) { + curl_easy_setopt(curl, CURLOPT_POST, 1L); + } else if (request.Method() == HttpMethod::kPut) { + curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "PUT"); + } else if (request.Method() == HttpMethod::kReport) { + curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "REPORT"); + } else if (request.Method() == HttpMethod::kGet) { + curl_easy_setopt(curl, CURLOPT_HTTPGET, 1L); + } + + // Set request body if present + if (request.Body().has_value()) { + const std::string& body = request.Body().value(); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body.c_str()); + curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, body.size()); + } + + // Set headers + struct curl_slist* headers = nullptr; + auto const& base_headers = request.Properties().BaseHeaders(); + for (auto const& [key, value] : base_headers) { + std::string header = key + ": " + value; + headers = curl_slist_append(headers, header.c_str()); + } + if (headers) { + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + } + + // Set timeouts (convert from milliseconds to seconds) + long connect_timeout = request.Properties().ConnectTimeout().count() / 1000; + long response_timeout = request.Properties().ResponseTimeout().count() / 1000; + + curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, connect_timeout > 0 ? connect_timeout : 30L); + curl_easy_setopt(curl, CURLOPT_TIMEOUT, response_timeout > 0 ? response_timeout : 60L); + + // Set TLS options + using VerifyMode = config::shared::built::TlsOptions::VerifyMode; + if (tls_options_.PeerVerifyMode() == VerifyMode::kVerifyNone) { + curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0L); + curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 0L); + } else { + curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L); + curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L); + + // Set custom CA file if provided + if (tls_options_.CustomCAFile().has_value()) { + curl_easy_setopt(curl, CURLOPT_CAINFO, tls_options_.CustomCAFile()->c_str()); + } + } + + // Set callbacks + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, WriteCallback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response_body); + curl_easy_setopt(curl, CURLOPT_HEADERFUNCTION, HeaderCallback); + curl_easy_setopt(curl, CURLOPT_HEADERDATA, &response_headers); + + // Follow redirects + curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); + curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 20L); + + // Perform the request + CURLcode res = curl_easy_perform(curl); + + // Cleanup headers + if (headers) { + curl_slist_free_all(headers); + } + + // Check for errors + if (res != CURLE_OK) { + std::string error_message = "CURL error: "; + error_message += curl_easy_strerror(res); + cb(HttpResult(error_message)); + return; + } + + // Get HTTP response code + long response_code = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &response_code); + + // Create success result + cb(HttpResult(static_cast(response_code), + std::move(response_body), + std::move(response_headers))); +} + } \ No newline at end of file diff --git a/libs/internal/tests/data_model_serialization_test.cpp b/libs/internal/tests/data_model_serialization_test.cpp index 66a3efe25..563d2e5f9 100644 --- a/libs/internal/tests/data_model_serialization_test.cpp +++ b/libs/internal/tests/data_model_serialization_test.cpp @@ -428,7 +428,7 @@ TEST(VariationOrRolloutTests, SerializeVariation) { #if BOOST_VERSION >= 108300 auto json = boost::json::value_from(variation, VariationOrRolloutContext()); #else - auto json = boost::json::value_from(var_or_roll); + auto json = boost::json::value_from(variation); #endif auto expected = boost::json::parse(R"({"variation":5})"); EXPECT_EQ(expected, json); From 04a8cf8e643eff9a196ca70059a758ec2cbc5aef Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 8 Oct 2025 16:35:01 -0700 Subject: [PATCH 17/90] WIP --- libs/internal/src/CMakeLists.txt | 1 + libs/internal/tests/curl_requester_test.cpp | 327 ++++++++++++++++++++ 2 files changed, 328 insertions(+) create mode 100644 libs/internal/tests/curl_requester_test.cpp diff --git a/libs/internal/src/CMakeLists.txt b/libs/internal/src/CMakeLists.txt index 811d7e540..a0914a81b 100644 --- a/libs/internal/src/CMakeLists.txt +++ b/libs/internal/src/CMakeLists.txt @@ -27,6 +27,7 @@ add_library(${LIBNAME} OBJECT logging/logger.cpp network/http_error_messages.cpp network/http_requester.cpp + network/curl_requester.cpp serialization/events/json_events.cpp serialization/json_attributes.cpp serialization/json_context.cpp diff --git a/libs/internal/tests/curl_requester_test.cpp b/libs/internal/tests/curl_requester_test.cpp new file mode 100644 index 000000000..723325ef0 --- /dev/null +++ b/libs/internal/tests/curl_requester_test.cpp @@ -0,0 +1,327 @@ +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace beast = boost::beast; +namespace http = beast::http; +namespace net = boost::asio; +using tcp = boost::asio::ip::tcp; + +using launchdarkly::config::shared::ClientSDK; +using launchdarkly::config::shared::builders::HttpPropertiesBuilder; +using launchdarkly::network::CurlRequester; +using launchdarkly::network::HttpMethod; +using launchdarkly::network::HttpRequest; +using launchdarkly::network::HttpResult; + +// Simple HTTP server for testing +class TestHttpServer { + public: + TestHttpServer(net::io_context& ioc, unsigned short port) + : acceptor_(ioc, tcp::endpoint(tcp::v4(), port)), + socket_(ioc) { + port_ = acceptor_.local_endpoint().port(); + } + + unsigned short port() const { return port_; } + + void Accept() { + acceptor_.async_accept(socket_, [this](beast::error_code ec) { + if (!ec) { + std::make_shared(std::move(socket_), handler_) + ->Run(); + } + Accept(); + }); + } + + void SetHandler( + std::function( + http::request const&)> handler) { + handler_ = std::move(handler); + } + + private: + class Session : public std::enable_shared_from_this { + public: + Session(tcp::socket socket, + std::function( + http::request const&)> handler) + : socket_(std::move(socket)), handler_(std::move(handler)) {} + + void Run() { + http::async_read( + socket_, buffer_, req_, + [self = shared_from_this()](beast::error_code ec, + std::size_t bytes_transferred) { + boost::ignore_unused(bytes_transferred); + if (!ec) { + self->HandleRequest(); + } + }); + } + + private: + void HandleRequest() { + http::response res = handler_(req_); + res.prepare_payload(); + + http::async_write(socket_, res, + [self = shared_from_this()]( + beast::error_code ec, std::size_t) { + self->socket_.shutdown( + tcp::socket::shutdown_send, ec); + }); + } + + tcp::socket socket_; + beast::flat_buffer buffer_; + http::request req_; + std::function( + http::request const&)> + handler_; + }; + + tcp::acceptor acceptor_; + tcp::socket socket_; + unsigned short port_; + std::function( + http::request const&)> + handler_; +}; + +class CurlRequesterTest : public ::testing::Test { + protected: + void SetUp() override { + server_ = std::make_unique(ioc_, 0); + server_->Accept(); + + // Run io_context in a separate thread + thread_ = std::thread([this]() { ioc_.run(); }); + + // Give the server time to start + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } + + void TearDown() override { + ioc_.stop(); + if (thread_.joinable()) { + thread_.join(); + } + } + + std::string GetServerUrl(std::string const& path = "/") { + return "http://127.0.0.1:" + std::to_string(server_->port()) + path; + } + + net::io_context ioc_; + std::unique_ptr server_; + std::thread thread_; +}; + +TEST_F(CurlRequesterTest, CanMakeBasicGetRequest) { + server_->SetHandler( + [](http::request const& req) + -> http::response { + EXPECT_EQ(http::verb::get, req.method()); + EXPECT_EQ("/test", req.target()); + + http::response res{http::status::ok, + req.version()}; + res.set(http::field::content_type, "text/plain"); + res.body() = "Hello, World!"; + return res; + }); + + net::io_context client_ioc; + CurlRequester requester( + client_ioc.get_executor(), + launchdarkly::config::shared::built::TlsOptions()); + + HttpRequest request(GetServerUrl("/test"), HttpMethod::kGet, + HttpPropertiesBuilder().Build(), + std::nullopt); + + bool callback_called = false; + HttpResult result(std::nullopt); + + requester.Request( + std::move(request), + [&callback_called, &result, &client_ioc](HttpResult const& res) { + callback_called = true; + result = res; + client_ioc.stop(); + }); + + client_ioc.run(); + + ASSERT_TRUE(callback_called); + EXPECT_FALSE(result.IsError()); + EXPECT_EQ(200, result.Status()); + EXPECT_EQ("Hello, World!", result.Body().value()); +} + +TEST_F(CurlRequesterTest, CanMakePostRequestWithBody) { + server_->SetHandler( + [](http::request const& req) + -> http::response { + EXPECT_EQ(http::verb::post, req.method()); + EXPECT_EQ("/echo", req.target()); + EXPECT_EQ("test data", req.body()); + + http::response res{http::status::ok, + req.version()}; + res.set(http::field::content_type, "text/plain"); + res.body() = "Received: " + std::string(req.body()); + return res; + }); + + net::io_context client_ioc; + CurlRequester requester( + client_ioc.get_executor(), + launchdarkly::config::shared::built::TlsOptions()); + + HttpRequest request(GetServerUrl("/echo"), HttpMethod::kPost, + HttpPropertiesBuilder().Build(), + "test data"); + + bool callback_called = false; + HttpResult result(std::nullopt); + + requester.Request( + std::move(request), + [&callback_called, &result, &client_ioc](HttpResult const& res) { + callback_called = true; + result = res; + client_ioc.stop(); + }); + + client_ioc.run(); + + ASSERT_TRUE(callback_called); + EXPECT_FALSE(result.IsError()); + EXPECT_EQ(200, result.Status()); + EXPECT_EQ("Received: test data", result.Body().value()); +} + +TEST_F(CurlRequesterTest, HandlesCustomHeaders) { + server_->SetHandler( + [](http::request const& req) + -> http::response { + auto header_it = req.find("X-Custom-Header"); + EXPECT_NE(req.end(), header_it); + if (header_it != req.end()) { + EXPECT_EQ("custom-value", header_it->value()); + } + + http::response res{http::status::ok, + req.version()}; + res.set("X-Response-Header", "response-value"); + res.body() = "OK"; + return res; + }); + + net::io_context client_ioc; + CurlRequester requester( + client_ioc.get_executor(), + launchdarkly::config::shared::built::TlsOptions()); + + auto properties = HttpPropertiesBuilder() + .Header("X-Custom-Header", "custom-value") + .Build(); + + HttpRequest request(GetServerUrl("/headers"), HttpMethod::kGet, + std::move(properties), std::nullopt); + + bool callback_called = false; + HttpResult result(std::nullopt); + + requester.Request( + std::move(request), + [&callback_called, &result, &client_ioc](HttpResult const& res) { + callback_called = true; + result = res; + client_ioc.stop(); + }); + + client_ioc.run(); + + ASSERT_TRUE(callback_called); + EXPECT_FALSE(result.IsError()); + EXPECT_EQ(200, result.Status()); + EXPECT_EQ(1, result.Headers().count("X-Response-Header")); + EXPECT_EQ("response-value", result.Headers().at("X-Response-Header")); +} + +TEST_F(CurlRequesterTest, Handles404Status) { + server_->SetHandler([](http::request const& req) + -> http::response { + http::response res{http::status::not_found, + req.version()}; + res.body() = "Not Found"; + return res; + }); + + net::io_context client_ioc; + CurlRequester requester( + client_ioc.get_executor(), + launchdarkly::config::shared::built::TlsOptions()); + + HttpRequest request(GetServerUrl("/notfound"), HttpMethod::kGet, + HttpPropertiesBuilder().Build(), + std::nullopt); + + bool callback_called = false; + HttpResult result(std::nullopt); + + requester.Request( + std::move(request), + [&callback_called, &result, &client_ioc](HttpResult const& res) { + callback_called = true; + result = res; + client_ioc.stop(); + }); + + client_ioc.run(); + + ASSERT_TRUE(callback_called); + EXPECT_FALSE(result.IsError()); + EXPECT_EQ(404, result.Status()); + EXPECT_EQ("Not Found", result.Body().value()); +} + +TEST_F(CurlRequesterTest, HandlesInvalidUrl) { + net::io_context client_ioc; + CurlRequester requester( + client_ioc.get_executor(), + launchdarkly::config::shared::built::TlsOptions()); + + HttpRequest request("not a valid url", HttpMethod::kGet, + HttpPropertiesBuilder().Build(), + std::nullopt); + + bool callback_called = false; + HttpResult result(std::nullopt); + + requester.Request( + std::move(request), + [&callback_called, &result, &client_ioc](HttpResult const& res) { + callback_called = true; + result = res; + client_ioc.stop(); + }); + + client_ioc.run(); + + ASSERT_TRUE(callback_called); + EXPECT_TRUE(result.IsError()); +} From 543e45021c4930c21809713343e151237e94f417 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 8 Oct 2025 16:42:18 -0700 Subject: [PATCH 18/90] Basics working --- libs/internal/tests/curl_requester_test.cpp | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/libs/internal/tests/curl_requester_test.cpp b/libs/internal/tests/curl_requester_test.cpp index 723325ef0..84556bf6e 100644 --- a/libs/internal/tests/curl_requester_test.cpp +++ b/libs/internal/tests/curl_requester_test.cpp @@ -72,10 +72,10 @@ class TestHttpServer { private: void HandleRequest() { - http::response res = handler_(req_); - res.prepare_payload(); + res_ = handler_(req_); + res_.prepare_payload(); - http::async_write(socket_, res, + http::async_write(socket_, res_, [self = shared_from_this()]( beast::error_code ec, std::size_t) { self->socket_.shutdown( @@ -86,6 +86,7 @@ class TestHttpServer { tcp::socket socket_; beast::flat_buffer buffer_; http::request req_; + http::response res_; std::function( http::request const&)> handler_; From 5a5b8b9c544ef3c6cd3c99fbb3dfb11458ddd9e2 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 9 Oct 2025 09:49:38 -0700 Subject: [PATCH 19/90] Streaming working. --- .../data_sources/streaming_data_source.cpp | 2 + .../launchdarkly/network/requester.hpp | 5 +- libs/internal/src/network/curl_requester.cpp | 24 +- .../include/launchdarkly/sse/client.hpp | 10 + libs/server-sent-events/src/CMakeLists.txt | 5 +- libs/server-sent-events/src/client.cpp | 17 +- libs/server-sent-events/src/curl_client.cpp | 489 ++++++++++++++++++ libs/server-sent-events/src/curl_client.hpp | 97 ++++ 8 files changed, 638 insertions(+), 11 deletions(-) create mode 100644 libs/server-sent-events/src/curl_client.cpp create mode 100644 libs/server-sent-events/src/curl_client.hpp diff --git a/libs/client-sdk/src/data_sources/streaming_data_source.cpp b/libs/client-sdk/src/data_sources/streaming_data_source.cpp index d28b36677..4a5c928a7 100644 --- a/libs/client-sdk/src/data_sources/streaming_data_source.cpp +++ b/libs/client-sdk/src/data_sources/streaming_data_source.cpp @@ -88,6 +88,8 @@ void StreamingDataSource::Start() { auto client_builder = launchdarkly::sse::Builder(exec_, url.buffer()); + client_builder.use_curl(true); + client_builder.method(data_source_config_.use_report ? boost::beast::http::verb::report : boost::beast::http::verb::get); diff --git a/libs/internal/include/launchdarkly/network/requester.hpp b/libs/internal/include/launchdarkly/network/requester.hpp index 1ae28738d..bfb39a14a 100644 --- a/libs/internal/include/launchdarkly/network/requester.hpp +++ b/libs/internal/include/launchdarkly/network/requester.hpp @@ -1,7 +1,8 @@ #pragma once #include "http_requester.hpp" -#include "asio_requester.hpp" +// #include "asio_requester.hpp" +#include "curl_requester.hpp" #include #include @@ -12,7 +13,7 @@ using TlsOptions = config::shared::built::TlsOptions; typedef std::function CallbackFunction; class Requester { - AsioRequester innerRequester_; + CurlRequester innerRequester_; public: Requester(net::any_io_executor ctx, TlsOptions const& tls_options): innerRequester_(ctx, tls_options) {} diff --git a/libs/internal/src/network/curl_requester.cpp b/libs/internal/src/network/curl_requester.cpp index ebab3bdf6..9f8375c87 100644 --- a/libs/internal/src/network/curl_requester.cpp +++ b/libs/internal/src/network/curl_requester.cpp @@ -72,13 +72,17 @@ void CurlRequester::Request(HttpRequest request, std::function cb) { // Validate request if (!request.Valid()) { - cb(HttpResult("The request was malformed and could not be made.")); + boost::asio::post(ctx_, [cb = std::move(cb)]() { + cb(HttpResult("The request was malformed and could not be made.")); + }); return; } CURL* curl = curl_easy_init(); if (!curl) { - cb(HttpResult("Failed to initialize CURL")); + boost::asio::post(ctx_, [cb = std::move(cb)]() { + cb(HttpResult("Failed to initialize CURL")); + }); return; } @@ -165,7 +169,9 @@ void CurlRequester::PerformRequest(HttpRequest request, std::function(response_code), - std::move(response_body), - std::move(response_headers))); + // Post the success result back to the executor + boost::asio::post(ctx_, [cb = std::move(cb), response_code, + response_body = std::move(response_body), + response_headers = std::move(response_headers)]() mutable { + cb(HttpResult(static_cast(response_code), + std::move(response_body), + std::move(response_headers))); + }); } } \ No newline at end of file diff --git a/libs/server-sent-events/include/launchdarkly/sse/client.hpp b/libs/server-sent-events/include/launchdarkly/sse/client.hpp index 2bf93989c..9108e3390 100644 --- a/libs/server-sent-events/include/launchdarkly/sse/client.hpp +++ b/libs/server-sent-events/include/launchdarkly/sse/client.hpp @@ -153,6 +153,15 @@ class Builder { */ Builder& custom_ca_file(std::string path); + /** + * Use the CURL-based implementation instead of the default Boost.Beast/Foxy + * implementation. + * + * @param use_curl True to use CURL implementation, false for Foxy. + * @return Reference to this builder. + */ + Builder& use_curl(bool use_curl); + /** * Builds a Client. The shared pointer is necessary to extend the lifetime * of the Client to encompass each asynchronous operation that it performs. @@ -174,6 +183,7 @@ class Builder { ErrorCallback error_cb_; bool skip_verify_peer_; std::optional custom_ca_file_; + bool use_curl_; }; /** diff --git a/libs/server-sent-events/src/CMakeLists.txt b/libs/server-sent-events/src/CMakeLists.txt index 721a07862..597f7f06c 100644 --- a/libs/server-sent-events/src/CMakeLists.txt +++ b/libs/server-sent-events/src/CMakeLists.txt @@ -8,15 +8,18 @@ file(GLOB HEADER_LIST CONFIGURE_DEPENDS add_library(${LIBNAME} OBJECT ${HEADER_LIST} client.cpp + curl_client.cpp parser.cpp event.cpp error.cpp backoff_detail.cpp backoff.cpp) +find_package(CURL) + target_link_libraries(${LIBNAME} PUBLIC OpenSSL::SSL Boost::headers foxy - PRIVATE Boost::url Boost::disable_autolinking + PRIVATE Boost::url Boost::disable_autolinking CURL::libcurl ) add_library(launchdarkly::sse ALIAS ${LIBNAME}) diff --git a/libs/server-sent-events/src/client.cpp b/libs/server-sent-events/src/client.cpp index 14c144a05..52c7232a0 100644 --- a/libs/server-sent-events/src/client.cpp +++ b/libs/server-sent-events/src/client.cpp @@ -8,6 +8,7 @@ #include "backoff.hpp" #include "parser.hpp" +#include "curl_client.hpp" #include #include @@ -513,7 +514,8 @@ Builder::Builder(net::any_io_executor ctx, std::string url) receiver_([](launchdarkly::sse::Event const&) {}), error_cb_([](auto err) {}), skip_verify_peer_(false), - custom_ca_file_(std::nullopt) { + custom_ca_file_(std::nullopt), + use_curl_(false) { request_.version(11); request_.set(http::field::user_agent, kDefaultUserAgent); request_.method(http::verb::get); @@ -585,6 +587,11 @@ Builder& Builder::custom_ca_file(std::string path) { return *this; } +Builder& Builder::use_curl(bool use_curl) { + use_curl_ = use_curl; + return *this; +} + std::shared_ptr Builder::build() { auto uri_components = boost::urls::parse_uri(url_); if (!uri_components) { @@ -627,6 +634,14 @@ std::shared_ptr Builder::build() { std::string service = uri_components->has_port() ? uri_components->port() : uri_components->scheme(); + if (use_curl_) { + return std::make_shared( + net::make_strand(executor_), request, host, service, + connect_timeout_, read_timeout_, write_timeout_, + initial_reconnect_delay_, receiver_, logging_cb_, error_cb_, + skip_verify_peer_, custom_ca_file_); + } + std::optional ssl; if (uri_components->scheme_id() == boost::urls::scheme::https) { ssl = launchdarkly::foxy::make_ssl_ctx(ssl::context::tlsv12_client); diff --git a/libs/server-sent-events/src/curl_client.cpp b/libs/server-sent-events/src/curl_client.cpp new file mode 100644 index 000000000..6433f2d42 --- /dev/null +++ b/libs/server-sent-events/src/curl_client.cpp @@ -0,0 +1,489 @@ +#include "curl_client.hpp" + +#include +#include +#include +#include + +#include + +namespace launchdarkly::sse { + +namespace beast = boost::beast; +namespace http = beast::http; + +// Time duration used when no timeout is specified (1 year). +auto const kNoTimeout = std::chrono::hours(8760); + +// Time duration that the backoff algorithm uses before initiating a new +// connection, the first time a failure is detected. +auto const kDefaultInitialReconnectDelay = std::chrono::seconds(1); + +// Maximum duration between backoff attempts. +auto const kDefaultMaxBackoffDelay = std::chrono::seconds(30); + +CurlClient::CurlClient(boost::asio::any_io_executor executor, + http::request req, + std::string host, + std::string port, + std::optional connect_timeout, + std::optional read_timeout, + std::optional write_timeout, + std::optional initial_reconnect_delay, + Builder::EventReceiver receiver, + Builder::LogCallback logger, + Builder::ErrorCallback errors, + bool skip_verify_peer, + std::optional custom_ca_file) + : executor_(std::move(executor)), + host_(std::move(host)), + port_(std::move(port)), + req_(std::move(req)), + connect_timeout_(connect_timeout), + read_timeout_(read_timeout), + write_timeout_(write_timeout), + event_receiver_(std::move(receiver)), + logger_(std::move(logger)), + errors_(std::move(errors)), + skip_verify_peer_(skip_verify_peer), + custom_ca_file_(std::move(custom_ca_file)), + backoff_(initial_reconnect_delay.value_or(kDefaultInitialReconnectDelay), + kDefaultMaxBackoffDelay), + backoff_timer_(executor_), + last_event_id_(std::nullopt), + current_event_(std::nullopt), + shutting_down_(false), + curl_active_(false), + buffered_line_(std::nullopt), + begin_CR_(false) { +} + +CurlClient::~CurlClient() { + shutting_down_ = true; + backoff_timer_.cancel(); + + if (request_thread_ && request_thread_->joinable()) { + request_thread_->join(); + } +} + +void CurlClient::async_connect() { + boost::asio::post(executor_, + [self = shared_from_this()]() { self->do_run(); }); +} + +void CurlClient::do_run() { + if (shutting_down_) { + return; + } + + // Start request in a separate thread since CURL blocks + request_thread_ = std::make_unique( + [self = shared_from_this()]() { self->perform_request(); }); + + // Detach so we don't have to wait for it during shutdown + request_thread_->detach(); + request_thread_.reset(); +} + +void CurlClient::async_backoff(std::string const& reason) { + backoff_.fail(); + + std::stringstream msg; + msg << "backing off in (" + << std::chrono::duration_cast(backoff_.delay()) + .count() + << ") seconds due to " << reason; + + log_message(msg.str()); + + backoff_timer_.expires_after(backoff_.delay()); + backoff_timer_.async_wait([self = shared_from_this()]( + boost::system::error_code ec) { + self->on_backoff(ec); + }); +} + +void CurlClient::on_backoff(boost::system::error_code ec) { + if (ec == boost::asio::error::operation_aborted || shutting_down_) { + return; + } + do_run(); +} + +std::string CurlClient::build_url() const { + std::string scheme = (port_ == "https" || port_ == "443") ? "https" : "http"; + + std::string url = scheme + "://" + host_; + + // Add port if it's not the default for the scheme + if ((scheme == "https" && port_ != "https" && port_ != "443") || + (scheme == "http" && port_ != "http" && port_ != "80")) { + url += ":" + port_; + } + + url += std::string(req_.target()); + + return url; +} + +void CurlClient::setup_curl_options(CURL* curl) { + // Set URL + curl_easy_setopt(curl, CURLOPT_URL, build_url().c_str()); + + // Set HTTP method + switch (req_.method()) { + case http::verb::get: + curl_easy_setopt(curl, CURLOPT_HTTPGET, 1L); + break; + case http::verb::post: + curl_easy_setopt(curl, CURLOPT_POST, 1L); + break; + case http::verb::report: + curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "REPORT"); + break; + default: + curl_easy_setopt(curl, CURLOPT_HTTPGET, 1L); + break; + } + + // Set request body if present + if (!req_.body().empty()) { + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, req_.body().c_str()); + curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, req_.body().size()); + } + + // Set headers + struct curl_slist* headers = nullptr; + for (auto const& field : req_) { + std::string header = std::string(field.name_string()) + ": " + + std::string(field.value()); + headers = curl_slist_append(headers, header.c_str()); + } + + // Add Last-Event-ID if we have one + if (last_event_id_ && !last_event_id_->empty()) { + std::string last_event_header = "Last-Event-ID: " + *last_event_id_; + headers = curl_slist_append(headers, last_event_header.c_str()); + } + + if (headers) { + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + } + + // Set timeouts + long connect_timeout_sec = connect_timeout_ + ? connect_timeout_->count() / 1000 + : kNoTimeout.count() / 1000; + curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, connect_timeout_sec); + + // For read timeout, we use low speed limit (bytes/sec) and low speed time + if (read_timeout_) { + curl_easy_setopt(curl, CURLOPT_LOW_SPEED_LIMIT, 1L); + curl_easy_setopt(curl, CURLOPT_LOW_SPEED_TIME, + read_timeout_->count() / 1000); + } + + // Set TLS options + if (skip_verify_peer_) { + curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0L); + curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 0L); + } else { + curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L); + curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L); + + if (custom_ca_file_) { + curl_easy_setopt(curl, CURLOPT_CAINFO, custom_ca_file_->c_str()); + } + } + + // Set callbacks + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, WriteCallback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, this); + curl_easy_setopt(curl, CURLOPT_HEADERFUNCTION, HeaderCallback); + curl_easy_setopt(curl, CURLOPT_HEADERDATA, this); + + // Don't follow redirects automatically - we'll handle them ourselves + curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 0L); +} + +size_t CurlClient::WriteCallback(char* data, size_t size, size_t nmemb, + void* userp) { + size_t total_size = size * nmemb; + auto* client = static_cast(userp); + + if (client->shutting_down_) { + return 0; // Abort the transfer + } + + // Parse SSE data + std::string_view body(data, total_size); + + // Parse stream into lines + size_t i = 0; + while (i < body.size()) { + // Find next line delimiter + size_t delimiter_pos = body.find_first_of("\r\n", i); + size_t append_size = (delimiter_pos == std::string::npos) + ? (body.size() - i) + : (delimiter_pos - i); + + // Append to buffered line + if (client->buffered_line_.has_value()) { + client->buffered_line_->append(body.substr(i, append_size)); + } else { + client->buffered_line_ = std::string(body.substr(i, append_size)); + } + + i += append_size; + + if (i >= body.size()) { + break; + } + + // Handle line delimiters + if (body[i] == '\r') { + client->complete_lines_.push_back(*client->buffered_line_); + client->buffered_line_.reset(); + client->begin_CR_ = true; + i++; + } else if (body[i] == '\n') { + if (client->begin_CR_) { + client->begin_CR_ = false; + } else { + client->complete_lines_.push_back(*client->buffered_line_); + client->buffered_line_.reset(); + } + i++; + } + } + + // Parse completed lines into events + while (!client->complete_lines_.empty()) { + std::string line = std::move(client->complete_lines_.front()); + client->complete_lines_.pop_front(); + + if (line.empty()) { + // Empty line indicates end of event + if (client->current_event_) { + // Trim trailing newline from data + if (!client->current_event_->data.empty() && + client->current_event_->data.back() == '\n') { + client->current_event_->data.pop_back(); + } + + // Dispatch event on executor thread + auto event_data = client->current_event_->data; + auto event_type = client->current_event_->type.empty() + ? "message" + : client->current_event_->type; + auto event_id = client->current_event_->id; + + boost::asio::post(client->executor_, + [receiver = client->event_receiver_, + type = std::move(event_type), + data = std::move(event_data), + id = std::move(event_id)]() { + receiver(Event(type, data, id)); + }); + + client->current_event_.reset(); + } + continue; + } + + // Parse field + size_t colon_pos = line.find(':'); + if (colon_pos == 0) { + // Comment line, dispatch it + std::string comment = line.substr(1); + boost::asio::post(client->executor_, + [receiver = client->event_receiver_, + comment = std::move(comment)]() { + receiver(Event("comment", comment)); + }); + continue; + } + + std::string field_name; + std::string field_value; + + if (colon_pos == std::string::npos) { + field_name = line; + field_value = ""; + } else { + field_name = line.substr(0, colon_pos); + field_value = line.substr(colon_pos + 1); + + // Remove leading space from value if present + if (!field_value.empty() && field_value[0] == ' ') { + field_value = field_value.substr(1); + } + } + + // Initialize event if needed + if (!client->current_event_) { + client->current_event_.emplace(detail::Event{}); + client->current_event_->id = client->last_event_id_; + } + + // Handle field + if (field_name == "event") { + client->current_event_->type = field_value; + } else if (field_name == "data") { + client->current_event_->data += field_value; + client->current_event_->data += '\n'; + } else if (field_name == "id") { + if (field_value.find('\0') == std::string::npos) { + client->last_event_id_ = field_value; + client->current_event_->id = field_value; + } + } + // retry field is ignored for now + } + + return total_size; +} + +size_t CurlClient::HeaderCallback(char* buffer, size_t size, size_t nitems, + void* userdata) { + size_t total_size = size * nitems; + auto* client = static_cast(userdata); + + std::string header(buffer, total_size); + + // Check for Content-Type header + if (header.find("Content-Type:") == 0 || + header.find("content-type:") == 0) { + if (header.find("text/event-stream") == std::string::npos) { + client->log_message("warning: unexpected Content-Type: " + header); + } + } + + return total_size; +} + +void CurlClient::perform_request() { + if (shutting_down_) { + return; + } + + curl_active_ = true; + + CURL* curl = curl_easy_init(); + if (!curl) { + boost::asio::post(executor_, [self = shared_from_this()]() { + self->async_backoff("failed to initialize CURL"); + }); + curl_active_ = false; + return; + } + + setup_curl_options(curl); + + // Perform the request + CURLcode res = curl_easy_perform(curl); + + // Get response code + long response_code = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &response_code); + + curl_easy_cleanup(curl); + + curl_active_ = false; + + if (shutting_down_) { + return; + } + + // Handle result + if (res != CURLE_OK) { + std::string error_msg = "CURL error: " + std::string(curl_easy_strerror(res)); + boost::asio::post(executor_, [self = shared_from_this(), + error_msg = std::move(error_msg)]() { + self->async_backoff(error_msg); + }); + return; + } + + // Handle HTTP status codes + auto status = static_cast(response_code); + auto status_class = http::to_status_class(status); + + if (status_class == http::status_class::successful) { + if (status == http::status::no_content) { + boost::asio::post(executor_, [self = shared_from_this()]() { + self->report_error(errors::UnrecoverableClientError{http::status::no_content}); + }); + return; + } + log_message("connected"); + backoff_.succeed(); + // Connection ended normally, reconnect + boost::asio::post(executor_, + [self = shared_from_this()]() { + self->async_backoff("connection closed normally"); + }); + return; + } + + if (status_class == http::status_class::client_error) { + bool recoverable = (status == http::status::bad_request || + status == http::status::request_timeout || + status == http::status::too_many_requests); + + if (recoverable) { + std::stringstream ss; + ss << "HTTP status " << static_cast(status); + boost::asio::post(executor_, [self = shared_from_this(), + reason = ss.str()]() { + self->async_backoff(reason); + }); + } else { + boost::asio::post(executor_, [self = shared_from_this(), status]() { + self->report_error(errors::UnrecoverableClientError{status}); + }); + } + return; + } + + // Server error or other - backoff and retry + std::stringstream ss; + ss << "HTTP status " << static_cast(status); + boost::asio::post(executor_, + [self = shared_from_this(), reason = ss.str()]() { + self->async_backoff(reason); + }); +} + +void CurlClient::async_shutdown(std::function completion) { + boost::asio::post(executor_, [self = shared_from_this(), + completion = std::move(completion)]() { + self->do_shutdown(std::move(completion)); + }); +} + +void CurlClient::do_shutdown(std::function completion) { + shutting_down_ = true; + backoff_timer_.cancel(); + + // Note: CURL requests in progress will be aborted via the write callback + // returning 0 when shutting_down_ is true + + if (completion) { + completion(); + } +} + +void CurlClient::log_message(std::string const& message) { + boost::asio::post(executor_, + [logger = logger_, message]() { logger(message); }); +} + +void CurlClient::report_error(Error error) { + boost::asio::post(executor_, [errors = errors_, error = std::move(error)]() { + errors(error); + }); +} + +} // namespace launchdarkly::sse diff --git a/libs/server-sent-events/src/curl_client.hpp b/libs/server-sent-events/src/curl_client.hpp new file mode 100644 index 000000000..cfaf4eece --- /dev/null +++ b/libs/server-sent-events/src/curl_client.hpp @@ -0,0 +1,97 @@ +#pragma once + +#include +#include "backoff.hpp" +#include "parser.hpp" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace launchdarkly::sse { + +namespace http = boost::beast::http; +namespace net = boost::asio; + +class CurlClient : public Client, + public std::enable_shared_from_this { + public: + CurlClient(boost::asio::any_io_executor executor, + http::request req, + std::string host, + std::string port, + std::optional connect_timeout, + std::optional read_timeout, + std::optional write_timeout, + std::optional initial_reconnect_delay, + Builder::EventReceiver receiver, + Builder::LogCallback logger, + Builder::ErrorCallback errors, + bool skip_verify_peer, + std::optional custom_ca_file); + + ~CurlClient() override; + + void async_connect() override; + void async_shutdown(std::function completion) override; + + private: + void do_run(); + void do_shutdown(std::function completion); + void async_backoff(std::string const& reason); + void on_backoff(boost::system::error_code ec); + void perform_request(); + + static size_t WriteCallback(char* data, size_t size, size_t nmemb, void* userp); + static size_t HeaderCallback(char* buffer, size_t size, size_t nitems, void* userdata); + + void log_message(std::string const& message); + void report_error(Error error); + + std::string build_url() const; + void setup_curl_options(CURL* curl); + + boost::asio::any_io_executor executor_; + std::string host_; + std::string port_; + http::request req_; + + std::optional connect_timeout_; + std::optional read_timeout_; + std::optional write_timeout_; + + Builder::EventReceiver event_receiver_; + Builder::LogCallback logger_; + Builder::ErrorCallback errors_; + + bool skip_verify_peer_; + std::optional custom_ca_file_; + + Backoff backoff_; + boost::asio::steady_timer backoff_timer_; + + std::optional last_event_id_; + std::optional current_event_; + + std::atomic shutting_down_; + std::atomic curl_active_; + + std::unique_ptr request_thread_; + std::mutex shutdown_mutex_; + + // SSE parser state + std::optional buffered_line_; + std::deque complete_lines_; + bool begin_CR_; +}; + +} // namespace launchdarkly::sse From 7f03df2e14c93fc6d487c05f3e50f7cfd3bf838b Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 9 Oct 2025 10:23:00 -0700 Subject: [PATCH 20/90] Comments and cleanup. --- .../launchdarkly/network/curl_requester.hpp | 2 +- libs/internal/src/network/curl_requester.cpp | 58 ++++++++++++++----- 2 files changed, 45 insertions(+), 15 deletions(-) diff --git a/libs/internal/include/launchdarkly/network/curl_requester.hpp b/libs/internal/include/launchdarkly/network/curl_requester.hpp index 10cecbe8c..f11dfd6d7 100644 --- a/libs/internal/include/launchdarkly/network/curl_requester.hpp +++ b/libs/internal/include/launchdarkly/network/curl_requester.hpp @@ -18,7 +18,7 @@ class CurlRequester { void Request(HttpRequest request, std::function cb); private: - void PerformRequest(HttpRequest request, std::function cb); + void PerformRequest(const HttpRequest& request, std::function cb) const; net::any_io_executor ctx_; TlsOptions tls_options_; diff --git a/libs/internal/src/network/curl_requester.cpp b/libs/internal/src/network/curl_requester.cpp index 9f8375c87..91016d0c0 100644 --- a/libs/internal/src/network/curl_requester.cpp +++ b/libs/internal/src/network/curl_requester.cpp @@ -6,17 +6,25 @@ namespace launchdarkly::network { + // Callback for writing response data -static size_t WriteCallback(void* contents, size_t size, size_t nmemb, void* userp) { - size_t total_size = size * nmemb; - std::string* str = static_cast(userp); - str->append(static_cast(contents), total_size); +// +// https://curl.se/libcurl/c/CURLOPT_WRITEFUNCTION.html +// Our userdata is a std::string which we accumulate the body in. +static size_t WriteCallback(void* contents, const size_t size, const size_t dataSize, void* userdata) { + const size_t total_size = size * dataSize; + const auto stringData = static_cast(userdata); + stringData->append(static_cast(contents), total_size); return total_size; } // Callback for reading request headers -static size_t HeaderCallback(char* buffer, size_t size, size_t nitems, void* userdata) { - size_t total_size = size * nitems; +// +// https://curl.se/libcurl/c/CURLOPT_HEADERFUNCTION.html +// Our user data is our HttpResult::HeadersType wich we populate with +// headers as we receive them. +static size_t HeaderCallback(const char* buffer, const size_t size, const size_t dataSize, void* userdata) { + const size_t total_size = size * dataSize; auto* headers = static_cast(userdata); std::string header(buffer, total_size); @@ -27,9 +35,8 @@ static size_t HeaderCallback(char* buffer, size_t size, size_t nitems, void* use } // Parse header - size_t colon_pos = header.find(':'); - if (colon_pos != std::string::npos) { - std::string key = header.substr(0, colon_pos); + if (const size_t colon_pos = header.find(':'); colon_pos != std::string::npos) { + const std::string key = header.substr(0, colon_pos); std::string value = header.substr(colon_pos + 1); // Trim whitespace @@ -64,12 +71,14 @@ CurlRequester::CurlRequester(net::any_io_executor ctx, TlsOptions const& tls_opt void CurlRequester::Request(HttpRequest request, std::function cb) { // Post the request to the executor to perform it asynchronously + // Implementation note: We may want to consider if we do this on its own thread + // and then post the result back to the executor. boost::asio::post(ctx_, [this, request = std::move(request), cb = std::move(cb)]() mutable { PerformRequest(std::move(request), std::move(cb)); }); } -void CurlRequester::PerformRequest(HttpRequest request, std::function cb) { +void CurlRequester::PerformRequest(const HttpRequest& request, std::function cb) const { // Validate request if (!request.Valid()) { boost::asio::post(ctx_, [cb = std::move(cb)]() { @@ -86,7 +95,7 @@ void CurlRequester::PerformRequest(HttpRequest request, std::function curl_guard(curl, curl_easy_cleanup); std::string response_body; @@ -98,6 +107,10 @@ void CurlRequester::PerformRequest(HttpRequest request, std::function 0 ? connect_timeout : 30L); curl_easy_setopt(curl, CURLOPT_TIMEOUT, response_timeout > 0 ? response_timeout : 60L); @@ -139,6 +166,9 @@ void CurlRequester::PerformRequest(HttpRequest request, std::function Date: Thu, 9 Oct 2025 10:32:17 -0700 Subject: [PATCH 21/90] String constants. --- .../launchdarkly/network/curl_requester.hpp | 2 +- libs/internal/src/network/curl_requester.cpp | 59 +++++++++---------- 2 files changed, 30 insertions(+), 31 deletions(-) diff --git a/libs/internal/include/launchdarkly/network/curl_requester.hpp b/libs/internal/include/launchdarkly/network/curl_requester.hpp index f11dfd6d7..eaefd7de0 100644 --- a/libs/internal/include/launchdarkly/network/curl_requester.hpp +++ b/libs/internal/include/launchdarkly/network/curl_requester.hpp @@ -15,7 +15,7 @@ class CurlRequester { public: CurlRequester(net::any_io_executor ctx, TlsOptions const& tls_options); - void Request(HttpRequest request, std::function cb); + void Request(HttpRequest request, std::function cb) const; private: void PerformRequest(const HttpRequest& request, std::function cb) const; diff --git a/libs/internal/src/network/curl_requester.cpp b/libs/internal/src/network/curl_requester.cpp index 91016d0c0..9c20e3e09 100644 --- a/libs/internal/src/network/curl_requester.cpp +++ b/libs/internal/src/network/curl_requester.cpp @@ -1,11 +1,26 @@ -#include "../../include/launchdarkly/network/curl_requester.hpp" #include "launchdarkly/network/curl_requester.hpp" #include #include -#include namespace launchdarkly::network { +// Custom HTTP method strings +static constexpr auto const* kHttpMethodPut = "PUT"; +static constexpr auto const* kHttpMethodReport = "REPORT"; + +// Header parsing constants +static constexpr auto kHttpPrefix = "HTTP/"; +static constexpr auto const* kCrLf = "\r\n"; +static constexpr auto const* kLf = "\n"; +static constexpr auto const* kWhitespace = " \t"; +static constexpr auto const* kWhitespaceWithNewlines = " \t\r\n"; +static constexpr auto const* kHeaderSeparator = ": "; + +// Error messages +static constexpr auto const* kErrorMalformedRequest = "The request was malformed and could not be made."; +static constexpr auto const* kErrorCurlInit = "Failed to initialize CURL"; +static constexpr auto const* kErrorHeaderAppend = "Failed to append headers to CURL"; +static constexpr auto const* kErrorCurlPrefix = "CURL error: "; // Callback for writing response data // @@ -21,7 +36,7 @@ static size_t WriteCallback(void* contents, const size_t size, const size_t data // Callback for reading request headers // // https://curl.se/libcurl/c/CURLOPT_HEADERFUNCTION.html -// Our user data is our HttpResult::HeadersType wich we populate with +// Our user data is our HttpResult::HeadersType that we populate with // headers as we receive them. static size_t HeaderCallback(const char* buffer, const size_t size, const size_t dataSize, void* userdata) { const size_t total_size = size * dataSize; @@ -30,7 +45,7 @@ static size_t HeaderCallback(const char* buffer, const size_t size, const size_t std::string header(buffer, total_size); // Skip status line and empty lines - if (header.find("HTTP/") == 0 || header == "\r\n" || header == "\n") { + if (header.find(kHttpPrefix) == 0 || header == kCrLf || header == kLf) { return total_size; } @@ -40,8 +55,8 @@ static size_t HeaderCallback(const char* buffer, const size_t size, const size_t std::string value = header.substr(colon_pos + 1); // Trim whitespace - value.erase(0, value.find_first_not_of(" \t")); - value.erase(value.find_last_not_of(" \t\r\n") + 1); + value.erase(0, value.find_first_not_of(kWhitespace)); + value.erase(value.find_last_not_of(kWhitespaceWithNewlines) + 1); headers->insert_or_assign(key, value); } @@ -49,27 +64,12 @@ static size_t HeaderCallback(const char* buffer, const size_t size, const size_t return total_size; } -// Convert HttpMethod to CURL method string -static const char* MethodToCurlString(HttpMethod method) { - switch (method) { - case HttpMethod::kGet: - return "GET"; - case HttpMethod::kPost: - return "POST"; - case HttpMethod::kPut: - return "PUT"; - case HttpMethod::kReport: - return "REPORT"; - } - return "GET"; -} - CurlRequester::CurlRequester(net::any_io_executor ctx, TlsOptions const& tls_options) : ctx_(std::move(ctx)), tls_options_(tls_options) { curl_global_init(CURL_GLOBAL_DEFAULT); } -void CurlRequester::Request(HttpRequest request, std::function cb) { +void CurlRequester::Request(HttpRequest request, std::function cb) const { // Post the request to the executor to perform it asynchronously // Implementation note: We may want to consider if we do this on its own thread // and then post the result back to the executor. @@ -82,7 +82,7 @@ void CurlRequester::PerformRequest(const HttpRequest& request, std::function Date: Thu, 9 Oct 2025 10:53:36 -0700 Subject: [PATCH 22/90] Updates --- libs/internal/tests/curl_requester_test.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/libs/internal/tests/curl_requester_test.cpp b/libs/internal/tests/curl_requester_test.cpp index 84556bf6e..4764223d5 100644 --- a/libs/internal/tests/curl_requester_test.cpp +++ b/libs/internal/tests/curl_requester_test.cpp @@ -26,7 +26,7 @@ using launchdarkly::network::HttpResult; // Simple HTTP server for testing class TestHttpServer { public: - TestHttpServer(net::io_context& ioc, unsigned short port) + TestHttpServer(net::io_context& ioc, const unsigned short port) : acceptor_(ioc, tcp::endpoint(tcp::v4(), port)), socket_(ioc) { port_ = acceptor_.local_endpoint().port(); @@ -35,7 +35,7 @@ class TestHttpServer { unsigned short port() const { return port_; } void Accept() { - acceptor_.async_accept(socket_, [this](beast::error_code ec) { + acceptor_.async_accept(socket_, [this](const beast::error_code& ec) { if (!ec) { std::make_shared(std::move(socket_), handler_) ->Run(); @@ -61,7 +61,7 @@ class TestHttpServer { void Run() { http::async_read( socket_, buffer_, req_, - [self = shared_from_this()](beast::error_code ec, + [self = shared_from_this()](const beast::error_code& ec, std::size_t bytes_transferred) { boost::ignore_unused(bytes_transferred); if (!ec) { @@ -120,7 +120,7 @@ class CurlRequesterTest : public ::testing::Test { } } - std::string GetServerUrl(std::string const& path = "/") { + std::string GetServerUrl(std::string const& path = "/") const { return "http://127.0.0.1:" + std::to_string(server_->port()) + path; } From 71fa311e59fd57c204c818ee26bdb5bb857156c2 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 9 Oct 2025 14:45:30 -0700 Subject: [PATCH 23/90] SSE tests working. --- contract-tests/sse-contract-tests/README.md | 24 +++ .../include/entity_manager.hpp | 5 +- .../sse-contract-tests/include/server.hpp | 4 +- .../sse-contract-tests/src/entity_manager.cpp | 10 +- .../sse-contract-tests/src/main.cpp | 17 +- .../sse-contract-tests/src/server.cpp | 5 +- libs/server-sent-events/src/curl_client.cpp | 157 +++++++++++++++--- libs/server-sent-events/src/curl_client.hpp | 11 +- 8 files changed, 200 insertions(+), 33 deletions(-) diff --git a/contract-tests/sse-contract-tests/README.md b/contract-tests/sse-contract-tests/README.md index 9a3e4367d..92e23465a 100644 --- a/contract-tests/sse-contract-tests/README.md +++ b/contract-tests/sse-contract-tests/README.md @@ -5,6 +5,30 @@ the other. This project implements the test service for the C++ EventSource client. +### Usage + +```bash +sse-tests [PORT] [--use-curl] +``` + +- `PORT`: Optional port number (defaults to 8123) +- `--use-curl`: Optional flag to use the CURL-based SSE client implementation instead of the default Boost.Beast/Foxy implementation + +Examples: +```bash +# Start on default port 8123 with Foxy client +./sse-tests + +# Start on port 9000 with Foxy client +./sse-tests 9000 + +# Start on default port with CURL client +./sse-tests --use-curl + +# Start on port 9000 with CURL client +./sse-tests 9000 --use-curl +``` + **session (session.hpp)** This provides a simple REST API for creating/destroying diff --git a/contract-tests/sse-contract-tests/include/entity_manager.hpp b/contract-tests/sse-contract-tests/include/entity_manager.hpp index 93a35f0b6..f6f71ee38 100644 --- a/contract-tests/sse-contract-tests/include/entity_manager.hpp +++ b/contract-tests/sse-contract-tests/include/entity_manager.hpp @@ -26,6 +26,7 @@ class EntityManager { boost::asio::any_io_executor executor_; launchdarkly::Logger& logger_; + bool use_curl_; public: /** @@ -33,9 +34,11 @@ class EntityManager { * entities (SSE clients + event channel back to test harness). * @param executor Executor. * @param logger Logger. + * @param use_curl Whether to use CURL implementation for SSE clients. */ EntityManager(boost::asio::any_io_executor executor, - launchdarkly::Logger& logger); + launchdarkly::Logger& logger, + bool use_curl = false); /** * Create an entity with the given configuration. * @param params Config of the entity. diff --git a/contract-tests/sse-contract-tests/include/server.hpp b/contract-tests/sse-contract-tests/include/server.hpp index 778b6b500..2d8158703 100644 --- a/contract-tests/sse-contract-tests/include/server.hpp +++ b/contract-tests/sse-contract-tests/include/server.hpp @@ -30,11 +30,13 @@ class server { * @param address Address to bind. * @param port Port to bind. * @param logger Logger. + * @param use_curl Whether to use CURL implementation for SSE clients. */ server(net::io_context& ioc, std::string const& address, unsigned short port, - launchdarkly::Logger& logger); + launchdarkly::Logger& logger, + bool use_curl = false); /** * Advertise an optional test-harness capability, such as "comments". * @param cap diff --git a/contract-tests/sse-contract-tests/src/entity_manager.cpp b/contract-tests/sse-contract-tests/src/entity_manager.cpp index 798d55ce5..0e2c2af6f 100644 --- a/contract-tests/sse-contract-tests/src/entity_manager.cpp +++ b/contract-tests/sse-contract-tests/src/entity_manager.cpp @@ -4,8 +4,9 @@ using launchdarkly::LogLevel; EntityManager::EntityManager(boost::asio::any_io_executor executor, - launchdarkly::Logger& logger) - : counter_{0}, executor_{std::move(executor)}, logger_{logger} {} + launchdarkly::Logger& logger, + bool use_curl) + : counter_{0}, executor_{std::move(executor)}, logger_{logger}, use_curl_{use_curl} {} std::optional EntityManager::create(ConfigParams const& params) { std::string id = std::to_string(counter_++); @@ -40,6 +41,11 @@ std::optional EntityManager::create(ConfigParams const& params) { std::chrono::milliseconds(*params.initialDelayMs)); } + // Use the global CURL setting from command line + if (use_curl_) { + client_builder.use_curl(true); + } + client_builder.logger([this](std::string msg) { LD_LOG(logger_, LogLevel::kDebug) << std::move(msg); }); diff --git a/contract-tests/sse-contract-tests/src/main.cpp b/contract-tests/sse-contract-tests/src/main.cpp index 0a9881466..d9e0d6528 100644 --- a/contract-tests/sse-contract-tests/src/main.cpp +++ b/contract-tests/sse-contract-tests/src/main.cpp @@ -22,16 +22,25 @@ int main(int argc, char* argv[]) { std::string const default_port = "8123"; std::string port = default_port; - if (argc == 2) { - port = - argv[1]; // NOLINT(cppcoreguidelines-pro-bounds-pointer-arithmetic) + bool use_curl = false; + + // Parse command line arguments + for (int i = 1; i < argc; ++i) { + std::string arg = argv[i]; // NOLINT(cppcoreguidelines-pro-bounds-pointer-arithmetic) + if (arg == "--use-curl") { + use_curl = true; + LD_LOG(logger, LogLevel::kInfo) << "Using CURL implementation for SSE clients"; + } else if (i == 1 && arg.find("--") != 0) { + // First non-flag argument is the port + port = arg; + } } try { net::io_context ioc{1}; server srv(ioc, "0.0.0.0", boost::lexical_cast(port), - logger); + logger, use_curl); srv.add_capability("headers"); srv.add_capability("comments"); diff --git a/contract-tests/sse-contract-tests/src/server.cpp b/contract-tests/sse-contract-tests/src/server.cpp index 5d009628d..a95685904 100644 --- a/contract-tests/sse-contract-tests/src/server.cpp +++ b/contract-tests/sse-contract-tests/src/server.cpp @@ -13,10 +13,11 @@ using launchdarkly::LogLevel; server::server(net::io_context& ioc, std::string const& address, unsigned short port, - launchdarkly::Logger& logger) + launchdarkly::Logger& logger, + bool use_curl) : listener_{ioc.get_executor(), tcp::endpoint(boost::asio::ip::make_address(address), port)}, - entity_manager_{ioc.get_executor(), logger}, + entity_manager_{ioc.get_executor(), logger, use_curl}, logger_{logger} { LD_LOG(logger_, LogLevel::kInfo) << "server: listening on " << address << ":" << port; diff --git a/libs/server-sent-events/src/curl_client.cpp b/libs/server-sent-events/src/curl_client.cpp index 6433f2d42..87d3ee7f9 100644 --- a/libs/server-sent-events/src/curl_client.cpp +++ b/libs/server-sent-events/src/curl_client.cpp @@ -127,7 +127,7 @@ std::string CurlClient::build_url() const { return url; } -void CurlClient::setup_curl_options(CURL* curl) { +struct curl_slist* CurlClient::setup_curl_options(CURL* curl) { // Set URL curl_easy_setopt(curl, CURLOPT_URL, build_url().c_str()); @@ -171,17 +171,19 @@ void CurlClient::setup_curl_options(CURL* curl) { curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); } - // Set timeouts - long connect_timeout_sec = connect_timeout_ - ? connect_timeout_->count() / 1000 - : kNoTimeout.count() / 1000; - curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, connect_timeout_sec); + // Set timeouts with millisecond precision + if (connect_timeout_) { + curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT_MS, connect_timeout_->count()); + } - // For read timeout, we use low speed limit (bytes/sec) and low speed time + // For read timeout, use progress callback if (read_timeout_) { - curl_easy_setopt(curl, CURLOPT_LOW_SPEED_LIMIT, 1L); - curl_easy_setopt(curl, CURLOPT_LOW_SPEED_TIME, - read_timeout_->count() / 1000); + effective_read_timeout_ = read_timeout_; + last_progress_time_ = std::chrono::steady_clock::now(); + last_download_amount_ = 0; + curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, ProgressCallback); + curl_easy_setopt(curl, CURLOPT_XFERINFODATA, this); + curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L); } // Set TLS options @@ -205,6 +207,38 @@ void CurlClient::setup_curl_options(CURL* curl) { // Don't follow redirects automatically - we'll handle them ourselves curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 0L); + + return headers; +} + +int CurlClient::ProgressCallback(void* clientp, curl_off_t dltotal, curl_off_t dlnow, + curl_off_t ultotal, curl_off_t ulnow) { + auto* client = static_cast(clientp); + + if (client->shutting_down_) { + return 1; // Abort the transfer + } + + // Check if we've exceeded the read timeout + if (client->effective_read_timeout_) { + auto now = std::chrono::steady_clock::now(); + + // If download amount has changed, update the last progress time + if (dlnow != client->last_download_amount_) { + client->last_download_amount_ = dlnow; + client->last_progress_time_ = now; + } else { + // No new data - check if we've exceeded the timeout + auto elapsed = std::chrono::duration_cast( + now - client->last_progress_time_); + + if (elapsed > *client->effective_read_timeout_) { + return 1; // Abort the transfer + } + } + } + + return 0; // Continue } size_t CurlClient::WriteCallback(char* data, size_t size, size_t nmemb, @@ -272,6 +306,11 @@ size_t CurlClient::WriteCallback(char* data, size_t size, size_t nmemb, client->current_event_->data.pop_back(); } + // Update last_event_id_ only when dispatching a completed event + if (client->current_event_->id) { + client->last_event_id_ = client->current_event_->id; + } + // Dispatch event on executor thread auto event_data = client->current_event_->data; auto event_type = client->current_event_->type.empty() @@ -335,7 +374,6 @@ size_t CurlClient::WriteCallback(char* data, size_t size, size_t nmemb, client->current_event_->data += '\n'; } else if (field_name == "id") { if (field_value.find('\0') == std::string::npos) { - client->last_event_id_ = field_value; client->current_event_->id = field_value; } } @@ -363,11 +401,54 @@ size_t CurlClient::HeaderCallback(char* buffer, size_t size, size_t nitems, return total_size; } +bool CurlClient::handle_redirect(long response_code, CURL* curl) { + // Check if this is a redirect status + if (response_code != 301 && response_code != 307) { + return false; + } + + // Get the Location header + char* location = nullptr; + curl_easy_getinfo(curl, CURLINFO_REDIRECT_URL, &location); + + if (!location || std::string(location).empty()) { + // Invalid redirect, let FoxyClient behavior handle it + return false; + } + + // Parse the redirect URL + auto location_url = boost::urls::parse_uri(location); + if (!location_url) { + report_error(errors::InvalidRedirectLocation{location}); + return true; + } + + // Update host and target + host_ = location_url->host(); + req_.set(http::field::host, host_); + req_.target(location_url->encoded_target()); + + if (location_url->has_port()) { + port_ = location_url->port(); + } else { + port_ = location_url->scheme(); + } + + // Signal that we should retry with the new location + return true; +} + void CurlClient::perform_request() { if (shutting_down_) { return; } + // Clear parser state for new connection + buffered_line_.reset(); + complete_lines_.clear(); + current_event_.reset(); + begin_CR_ = false; + curl_active_ = true; CURL* curl = curl_easy_init(); @@ -379,7 +460,7 @@ void CurlClient::perform_request() { return; } - setup_curl_options(curl); + struct curl_slist* headers = setup_curl_options(curl); // Perform the request CURLcode res = curl_easy_perform(curl); @@ -388,8 +469,36 @@ void CurlClient::perform_request() { long response_code = 0; curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &response_code); - curl_easy_cleanup(curl); + // Free headers before cleanup + if (headers) { + curl_slist_free_all(headers); + } + + // Handle HTTP status codes + auto status = static_cast(response_code); + auto status_class = http::to_status_class(status); + + // Handle redirects + if (status_class == http::status_class::redirection) { + bool should_redirect = handle_redirect(response_code, curl); + curl_easy_cleanup(curl); + curl_active_ = false; + + if (should_redirect) { + // Retry with new location + if (!shutting_down_) { + perform_request(); + } + return; + } + // Invalid redirect - report error + boost::asio::post(executor_, [self = shared_from_this()]() { + self->report_error(errors::NotRedirectable{}); + }); + return; + } + curl_easy_cleanup(curl); curl_active_ = false; if (shutting_down_) { @@ -398,18 +507,22 @@ void CurlClient::perform_request() { // Handle result if (res != CURLE_OK) { - std::string error_msg = "CURL error: " + std::string(curl_easy_strerror(res)); - boost::asio::post(executor_, [self = shared_from_this(), - error_msg = std::move(error_msg)]() { - self->async_backoff(error_msg); - }); + // Check if the error was due to progress callback aborting (read timeout) + if (res == CURLE_ABORTED_BY_CALLBACK && effective_read_timeout_) { + boost::asio::post(executor_, [self = shared_from_this()]() { + self->report_error(errors::ReadTimeout{self->read_timeout_}); + self->async_backoff("aborting read of response body (timeout)"); + }); + } else { + std::string error_msg = "CURL error: " + std::string(curl_easy_strerror(res)); + boost::asio::post(executor_, [self = shared_from_this(), + error_msg = std::move(error_msg)]() { + self->async_backoff(error_msg); + }); + } return; } - // Handle HTTP status codes - auto status = static_cast(response_code); - auto status_class = http::to_status_class(status); - if (status_class == http::status_class::successful) { if (status == http::status::no_content) { boost::asio::post(executor_, [self = shared_from_this()]() { diff --git a/libs/server-sent-events/src/curl_client.hpp b/libs/server-sent-events/src/curl_client.hpp index cfaf4eece..2e2df7479 100644 --- a/libs/server-sent-events/src/curl_client.hpp +++ b/libs/server-sent-events/src/curl_client.hpp @@ -58,7 +58,11 @@ class CurlClient : public Client, void report_error(Error error); std::string build_url() const; - void setup_curl_options(CURL* curl); + struct curl_slist* setup_curl_options(CURL* curl); + bool handle_redirect(long response_code, CURL* curl); + + static int ProgressCallback(void* clientp, curl_off_t dltotal, curl_off_t dlnow, + curl_off_t ultotal, curl_off_t ulnow); boost::asio::any_io_executor executor_; std::string host_; @@ -92,6 +96,11 @@ class CurlClient : public Client, std::optional buffered_line_; std::deque complete_lines_; bool begin_CR_; + + // Progress tracking for read timeout + std::chrono::steady_clock::time_point last_progress_time_; + curl_off_t last_download_amount_; + std::optional effective_read_timeout_; }; } // namespace launchdarkly::sse From c60a03b5d68198b47cac72d2a3f6b20ed9bf9bf7 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 9 Oct 2025 15:14:24 -0700 Subject: [PATCH 24/90] Contract test passing. --- .../launchdarkly/network/curl_requester.hpp | 3 +- libs/internal/src/network/curl_requester.cpp | 48 +++++++++++-------- 2 files changed, 29 insertions(+), 22 deletions(-) diff --git a/libs/internal/include/launchdarkly/network/curl_requester.hpp b/libs/internal/include/launchdarkly/network/curl_requester.hpp index eaefd7de0..c1ae50f7b 100644 --- a/libs/internal/include/launchdarkly/network/curl_requester.hpp +++ b/libs/internal/include/launchdarkly/network/curl_requester.hpp @@ -18,7 +18,8 @@ class CurlRequester { void Request(HttpRequest request, std::function cb) const; private: - void PerformRequest(const HttpRequest& request, std::function cb) const; + static void PerformRequestStatic(net::any_io_executor ctx, TlsOptions const& tls_options, + const HttpRequest& request, std::function cb); net::any_io_executor ctx_; TlsOptions tls_options_; diff --git a/libs/internal/src/network/curl_requester.cpp b/libs/internal/src/network/curl_requester.cpp index 9c20e3e09..fd2246850 100644 --- a/libs/internal/src/network/curl_requester.cpp +++ b/libs/internal/src/network/curl_requester.cpp @@ -71,17 +71,20 @@ CurlRequester::CurlRequester(net::any_io_executor ctx, TlsOptions const& tls_opt void CurlRequester::Request(HttpRequest request, std::function cb) const { // Post the request to the executor to perform it asynchronously - // Implementation note: We may want to consider if we do this on its own thread - // and then post the result back to the executor. - boost::asio::post(ctx_, [this, request = std::move(request), cb = std::move(cb)]() mutable { - PerformRequest(std::move(request), std::move(cb)); + // Copy ctx_ and tls_options_ to avoid capturing 'this' and causing use-after-free + // if the CurlRequester is destroyed while the operation is in flight. + auto ctx = ctx_; + auto tls_options = tls_options_; + boost::asio::post(ctx, [ctx, tls_options, request = std::move(request), cb = std::move(cb)]() mutable { + PerformRequestStatic(ctx, tls_options, std::move(request), std::move(cb)); }); } -void CurlRequester::PerformRequest(const HttpRequest& request, std::function cb) const { +void CurlRequester::PerformRequestStatic(net::any_io_executor ctx, TlsOptions const& tls_options, + const HttpRequest& request, std::function cb) { // Validate request if (!request.Valid()) { - boost::asio::post(ctx_, [cb = std::move(cb)]() { + boost::asio::post(ctx, [cb = std::move(cb)]() { cb(HttpResult(kErrorMalformedRequest)); }); return; @@ -89,7 +92,7 @@ void CurlRequester::PerformRequest(const HttpRequest& request, std::function 0 ? connect_timeout : 30L); - curl_easy_setopt(curl, CURLOPT_TIMEOUT, response_timeout > 0 ? response_timeout : 60L); + curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT_MS, connect_timeout_ms > 0 ? connect_timeout_ms : 30000L); + curl_easy_setopt(curl, CURLOPT_TIMEOUT_MS, response_timeout_ms > 0 ? response_timeout_ms : 60000L); // Set TLS options using VerifyMode = config::shared::built::TlsOptions::VerifyMode; - if (tls_options_.PeerVerifyMode() == VerifyMode::kVerifyNone) { + if (tls_options.PeerVerifyMode() == VerifyMode::kVerifyNone) { curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0L); curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 0L); } else { @@ -171,8 +177,8 @@ void CurlRequester::PerformRequest(const HttpRequest& request, std::functionc_str()); + if (tls_options.CustomCAFile().has_value()) { + curl_easy_setopt(curl, CURLOPT_CAINFO, tls_options.CustomCAFile()->c_str()); } } @@ -198,7 +204,7 @@ void CurlRequester::PerformRequest(const HttpRequest& request, std::function(response_code), std::move(response_body), std::move(response_headers))); From 023583487d99f94d24f33bc73d038dfe996b9fe8 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 10 Oct 2025 14:33:53 -0700 Subject: [PATCH 25/90] Refactoring for more deterministic destruction. --- libs/server-sent-events/src/curl_client.cpp | 208 +++++++++++++------- libs/server-sent-events/src/curl_client.hpp | 11 +- 2 files changed, 146 insertions(+), 73 deletions(-) diff --git a/libs/server-sent-events/src/curl_client.cpp b/libs/server-sent-events/src/curl_client.cpp index 87d3ee7f9..d41299e1d 100644 --- a/libs/server-sent-events/src/curl_client.cpp +++ b/libs/server-sent-events/src/curl_client.cpp @@ -35,7 +35,7 @@ CurlClient::CurlClient(boost::asio::any_io_executor executor, Builder::ErrorCallback errors, bool skip_verify_peer, std::optional custom_ca_file) - : executor_(std::move(executor)), + : host_(std::move(host)), port_(std::move(port)), req_(std::move(req)), @@ -49,26 +49,38 @@ CurlClient::CurlClient(boost::asio::any_io_executor executor, custom_ca_file_(std::move(custom_ca_file)), backoff_(initial_reconnect_delay.value_or(kDefaultInitialReconnectDelay), kDefaultMaxBackoffDelay), - backoff_timer_(executor_), last_event_id_(std::nullopt), current_event_(std::nullopt), shutting_down_(false), - curl_active_(false), + curl_socket_(CURL_SOCKET_BAD), buffered_line_(std::nullopt), - begin_CR_(false) { + begin_CR_(false), + backoff_timer_(std::move(executor)) { } CurlClient::~CurlClient() { shutting_down_ = true; backoff_timer_.cancel(); - if (request_thread_ && request_thread_->joinable()) { - request_thread_->join(); + // Close the socket to abort the CURL operation + curl_socket_t sock = curl_socket_.load(); + if (sock != CURL_SOCKET_BAD) { +#ifdef _WIN32 + closesocket(sock); +#else + close(sock); +#endif + } + + // Clear keepalive reference + { + std::lock_guard lock(request_thread_mutex_); + keepalive_.reset(); } } void CurlClient::async_connect() { - boost::asio::post(executor_, + boost::asio::post(backoff_timer_.get_executor(), [self = shared_from_this()]() { self->do_run(); }); } @@ -78,12 +90,18 @@ void CurlClient::do_run() { } // Start request in a separate thread since CURL blocks - request_thread_ = std::make_unique( - [self = shared_from_this()]() { self->perform_request(); }); + { + std::lock_guard request_thread_guard(request_thread_mutex_); + + // Store a keepalive reference to ensure destructor doesn't run on request thread + keepalive_ = shared_from_this(); - // Detach so we don't have to wait for it during shutdown - request_thread_->detach(); - request_thread_.reset(); + // Capture only raw 'this' pointer, not shared_ptr + request_thread_ = std::make_unique( + [this]() { this->perform_request(); }); + + request_thread_->detach(); + } } void CurlClient::async_backoff(std::string const& reason) { @@ -204,6 +222,8 @@ struct curl_slist* CurlClient::setup_curl_options(CURL* curl) { curl_easy_setopt(curl, CURLOPT_WRITEDATA, this); curl_easy_setopt(curl, CURLOPT_HEADERFUNCTION, HeaderCallback); curl_easy_setopt(curl, CURLOPT_HEADERDATA, this); + curl_easy_setopt(curl, CURLOPT_OPENSOCKETFUNCTION, OpenSocketCallback); + curl_easy_setopt(curl, CURLOPT_OPENSOCKETDATA, this); // Don't follow redirects automatically - we'll handle them ourselves curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 0L); @@ -241,6 +261,22 @@ int CurlClient::ProgressCallback(void* clientp, curl_off_t dltotal, curl_off_t d return 0; // Continue } +curl_socket_t CurlClient::OpenSocketCallback(void* clientp, + curlsocktype purpose, + struct curl_sockaddr* address) { + auto* client = static_cast(clientp); + + // Create the socket + curl_socket_t sockfd = socket(address->family, address->socktype, address->protocol); + + // Store it so we can close it during shutdown + if (sockfd != CURL_SOCKET_BAD) { + client->curl_socket_ = sockfd; + } + + return sockfd; +} + size_t CurlClient::WriteCallback(char* data, size_t size, size_t nmemb, void* userp) { size_t total_size = size * nmemb; @@ -318,7 +354,7 @@ size_t CurlClient::WriteCallback(char* data, size_t size, size_t nmemb, : client->current_event_->type; auto event_id = client->current_event_->id; - boost::asio::post(client->executor_, + boost::asio::post(client->backoff_timer_.get_executor(), [receiver = client->event_receiver_, type = std::move(event_type), data = std::move(event_data), @@ -336,7 +372,7 @@ size_t CurlClient::WriteCallback(char* data, size_t size, size_t nmemb, if (colon_pos == 0) { // Comment line, dispatch it std::string comment = line.substr(1); - boost::asio::post(client->executor_, + boost::asio::post(client->backoff_timer_.get_executor(), [receiver = client->event_receiver_, comment = std::move(comment)]() { receiver(Event("comment", comment)); @@ -439,6 +475,27 @@ bool CurlClient::handle_redirect(long response_code, CURL* curl) { } void CurlClient::perform_request() { + // RAII guard to clear keepalive when function exits + // This ensures destructor never runs on this thread + struct KeepaliveGuard { + CurlClient* client; + explicit KeepaliveGuard(CurlClient* c) : client(c) {} + ~KeepaliveGuard() { + if (!client->shutting_down_) { + // Post to executor so keepalive is cleared on executor thread + boost::asio::post(client->backoff_timer_.get_executor(), + [client = this->client]() { + std::lock_guard lock(client->request_thread_mutex_); + client->keepalive_.reset(); + }); + } else { + // During shutdown, clear immediately since executor may be gone + std::lock_guard lock(client->request_thread_mutex_); + client->keepalive_.reset(); + } + } + } guard(this); + if (shutting_down_) { return; } @@ -449,14 +506,13 @@ void CurlClient::perform_request() { current_event_.reset(); begin_CR_ = false; - curl_active_ = true; - CURL* curl = curl_easy_init(); if (!curl) { - boost::asio::post(executor_, [self = shared_from_this()]() { - self->async_backoff("failed to initialize CURL"); - }); - curl_active_ = false; + if (!shutting_down_) { + boost::asio::post(backoff_timer_.get_executor(), [self = shared_from_this()]() { + self->async_backoff("failed to initialize CURL"); + }); + } return; } @@ -482,7 +538,6 @@ void CurlClient::perform_request() { if (status_class == http::status_class::redirection) { bool should_redirect = handle_redirect(response_code, curl); curl_easy_cleanup(curl); - curl_active_ = false; if (should_redirect) { // Retry with new location @@ -492,14 +547,15 @@ void CurlClient::perform_request() { return; } // Invalid redirect - report error - boost::asio::post(executor_, [self = shared_from_this()]() { - self->report_error(errors::NotRedirectable{}); - }); + if (!shutting_down_) { + boost::asio::post(backoff_timer_.get_executor(), [self = shared_from_this()]() { + self->report_error(errors::NotRedirectable{}); + }); + } return; } curl_easy_cleanup(curl); - curl_active_ = false; if (shutting_down_) { return; @@ -507,70 +563,84 @@ void CurlClient::perform_request() { // Handle result if (res != CURLE_OK) { - // Check if the error was due to progress callback aborting (read timeout) - if (res == CURLE_ABORTED_BY_CALLBACK && effective_read_timeout_) { - boost::asio::post(executor_, [self = shared_from_this()]() { - self->report_error(errors::ReadTimeout{self->read_timeout_}); - self->async_backoff("aborting read of response body (timeout)"); - }); - } else { - std::string error_msg = "CURL error: " + std::string(curl_easy_strerror(res)); - boost::asio::post(executor_, [self = shared_from_this(), - error_msg = std::move(error_msg)]() { - self->async_backoff(error_msg); - }); + if (!shutting_down_) { + // Check if the error was due to progress callback aborting (read timeout) + if (res == CURLE_ABORTED_BY_CALLBACK && effective_read_timeout_) { + boost::asio::post(backoff_timer_.get_executor(), [self = shared_from_this()]() { + self->report_error(errors::ReadTimeout{self->read_timeout_}); + self->async_backoff("aborting read of response body (timeout)"); + }); + } else { + std::string error_msg = "CURL error: " + std::string(curl_easy_strerror(res)); + boost::asio::post(backoff_timer_.get_executor(), [self = shared_from_this(), + error_msg = std::move(error_msg)]() { + self->async_backoff(error_msg); + }); + } } return; } if (status_class == http::status_class::successful) { if (status == http::status::no_content) { - boost::asio::post(executor_, [self = shared_from_this()]() { - self->report_error(errors::UnrecoverableClientError{http::status::no_content}); - }); + if (!shutting_down_) { + boost::asio::post(backoff_timer_.get_executor(), [self = shared_from_this()]() { + self->report_error(errors::UnrecoverableClientError{http::status::no_content}); + }); + } return; } - log_message("connected"); + if (!shutting_down_) { + log_message("connected"); + } backoff_.succeed(); // Connection ended normally, reconnect - boost::asio::post(executor_, - [self = shared_from_this()]() { - self->async_backoff("connection closed normally"); - }); + if (!shutting_down_) { + boost::asio::post(backoff_timer_.get_executor(), + [self = shared_from_this()]() { + self->async_backoff("connection closed normally"); + }); + } return; } if (status_class == http::status_class::client_error) { - bool recoverable = (status == http::status::bad_request || - status == http::status::request_timeout || - status == http::status::too_many_requests); - - if (recoverable) { - std::stringstream ss; - ss << "HTTP status " << static_cast(status); - boost::asio::post(executor_, [self = shared_from_this(), - reason = ss.str()]() { - self->async_backoff(reason); - }); - } else { - boost::asio::post(executor_, [self = shared_from_this(), status]() { - self->report_error(errors::UnrecoverableClientError{status}); - }); + if (!shutting_down_) { + bool recoverable = (status == http::status::bad_request || + status == http::status::request_timeout || + status == http::status::too_many_requests); + + if (recoverable) { + std::stringstream ss; + ss << "HTTP status " << static_cast(status); + boost::asio::post(backoff_timer_.get_executor(), [self = shared_from_this(), + reason = ss.str()]() { + self->async_backoff(reason); + }); + } else { + boost::asio::post(backoff_timer_.get_executor(), [self = shared_from_this(), status]() { + self->report_error(errors::UnrecoverableClientError{status}); + }); + } } return; } // Server error or other - backoff and retry - std::stringstream ss; - ss << "HTTP status " << static_cast(status); - boost::asio::post(executor_, - [self = shared_from_this(), reason = ss.str()]() { - self->async_backoff(reason); - }); + if (!shutting_down_) { + std::stringstream ss; + ss << "HTTP status " << static_cast(status); + boost::asio::post(backoff_timer_.get_executor(), + [self = shared_from_this(), reason = ss.str()]() { + self->async_backoff(reason); + }); + } + + // Keepalive will be cleared by guard's destructor when function exits } void CurlClient::async_shutdown(std::function completion) { - boost::asio::post(executor_, [self = shared_from_this(), + boost::asio::post(backoff_timer_.get_executor(), [self = shared_from_this(), completion = std::move(completion)]() { self->do_shutdown(std::move(completion)); }); @@ -589,12 +659,12 @@ void CurlClient::do_shutdown(std::function completion) { } void CurlClient::log_message(std::string const& message) { - boost::asio::post(executor_, + boost::asio::post(backoff_timer_.get_executor(), [logger = logger_, message]() { logger(message); }); } void CurlClient::report_error(Error error) { - boost::asio::post(executor_, [errors = errors_, error = std::move(error)]() { + boost::asio::post(backoff_timer_.get_executor(), [errors = errors_, error = std::move(error)]() { errors(error); }); } diff --git a/libs/server-sent-events/src/curl_client.hpp b/libs/server-sent-events/src/curl_client.hpp index 2e2df7479..8d6fb3568 100644 --- a/libs/server-sent-events/src/curl_client.hpp +++ b/libs/server-sent-events/src/curl_client.hpp @@ -53,6 +53,7 @@ class CurlClient : public Client, static size_t WriteCallback(char* data, size_t size, size_t nmemb, void* userp); static size_t HeaderCallback(char* buffer, size_t size, size_t nitems, void* userdata); + static curl_socket_t OpenSocketCallback(void* clientp, curlsocktype purpose, struct curl_sockaddr* address); void log_message(std::string const& message); void report_error(Error error); @@ -64,7 +65,6 @@ class CurlClient : public Client, static int ProgressCallback(void* clientp, curl_off_t dltotal, curl_off_t dlnow, curl_off_t ultotal, curl_off_t ulnow); - boost::asio::any_io_executor executor_; std::string host_; std::string port_; http::request req_; @@ -81,16 +81,18 @@ class CurlClient : public Client, std::optional custom_ca_file_; Backoff backoff_; - boost::asio::steady_timer backoff_timer_; std::optional last_event_id_; std::optional current_event_; std::atomic shutting_down_; - std::atomic curl_active_; + std::atomic curl_socket_; std::unique_ptr request_thread_; - std::mutex shutdown_mutex_; + std::mutex request_thread_mutex_; + + // Keepalive reference to prevent destructor from running on request thread + std::shared_ptr keepalive_; // SSE parser state std::optional buffered_line_; @@ -101,6 +103,7 @@ class CurlClient : public Client, std::chrono::steady_clock::time_point last_progress_time_; curl_off_t last_download_amount_; std::optional effective_read_timeout_; + boost::asio::steady_timer backoff_timer_; }; } // namespace launchdarkly::sse From a870f033d36a7f7d822cdc58c5c970ff8d98b101 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 10 Oct 2025 15:06:08 -0700 Subject: [PATCH 26/90] Passing contract tests. --- libs/server-sent-events/src/client.cpp | 3 ++- libs/server-sent-events/src/curl_client.cpp | 12 +++++++----- libs/server-sent-events/src/curl_client.hpp | 4 +++- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/libs/server-sent-events/src/client.cpp b/libs/server-sent-events/src/client.cpp index 52c7232a0..8d057c1c2 100644 --- a/libs/server-sent-events/src/client.cpp +++ b/libs/server-sent-events/src/client.cpp @@ -635,11 +635,12 @@ std::shared_ptr Builder::build() { : uri_components->scheme(); if (use_curl_) { + bool use_https = uri_components->scheme_id() == boost::urls::scheme::https; return std::make_shared( net::make_strand(executor_), request, host, service, connect_timeout_, read_timeout_, write_timeout_, initial_reconnect_delay_, receiver_, logging_cb_, error_cb_, - skip_verify_peer_, custom_ca_file_); + skip_verify_peer_, custom_ca_file_, use_https); } std::optional ssl; diff --git a/libs/server-sent-events/src/curl_client.cpp b/libs/server-sent-events/src/curl_client.cpp index d41299e1d..efcce0100 100644 --- a/libs/server-sent-events/src/curl_client.cpp +++ b/libs/server-sent-events/src/curl_client.cpp @@ -34,7 +34,8 @@ CurlClient::CurlClient(boost::asio::any_io_executor executor, Builder::LogCallback logger, Builder::ErrorCallback errors, bool skip_verify_peer, - std::optional custom_ca_file) + std::optional custom_ca_file, + bool use_https) : host_(std::move(host)), port_(std::move(port)), @@ -47,6 +48,7 @@ CurlClient::CurlClient(boost::asio::any_io_executor executor, errors_(std::move(errors)), skip_verify_peer_(skip_verify_peer), custom_ca_file_(std::move(custom_ca_file)), + use_https_(use_https), backoff_(initial_reconnect_delay.value_or(kDefaultInitialReconnectDelay), kDefaultMaxBackoffDelay), last_event_id_(std::nullopt), @@ -130,13 +132,13 @@ void CurlClient::on_backoff(boost::system::error_code ec) { } std::string CurlClient::build_url() const { - std::string scheme = (port_ == "https" || port_ == "443") ? "https" : "http"; + std::string scheme = use_https_ ? "https" : "http"; std::string url = scheme + "://" + host_; - // Add port if it's not the default for the scheme - if ((scheme == "https" && port_ != "https" && port_ != "443") || - (scheme == "http" && port_ != "http" && port_ != "80")) { + // Add port if it's not the default service name + // port_ can be either a port number (like "8123") or service name (like "https"/"http") + if (port_ != "https" && port_ != "http") { url += ":" + port_; } diff --git a/libs/server-sent-events/src/curl_client.hpp b/libs/server-sent-events/src/curl_client.hpp index 8d6fb3568..1cfc3a248 100644 --- a/libs/server-sent-events/src/curl_client.hpp +++ b/libs/server-sent-events/src/curl_client.hpp @@ -37,7 +37,8 @@ class CurlClient : public Client, Builder::LogCallback logger, Builder::ErrorCallback errors, bool skip_verify_peer, - std::optional custom_ca_file); + std::optional custom_ca_file, + bool use_https); ~CurlClient() override; @@ -79,6 +80,7 @@ class CurlClient : public Client, bool skip_verify_peer_; std::optional custom_ca_file_; + bool use_https_; Backoff backoff_; From 832183ea3eda3b4c548680d3111dfd9b42a617f6 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 10 Oct 2025 15:12:38 -0700 Subject: [PATCH 27/90] Join curl thread on shutdown. --- libs/server-sent-events/src/curl_client.cpp | 56 ++++++++++++++------- 1 file changed, 38 insertions(+), 18 deletions(-) diff --git a/libs/server-sent-events/src/curl_client.cpp b/libs/server-sent-events/src/curl_client.cpp index efcce0100..f92b74d5f 100644 --- a/libs/server-sent-events/src/curl_client.cpp +++ b/libs/server-sent-events/src/curl_client.cpp @@ -74,9 +74,16 @@ CurlClient::~CurlClient() { #endif } - // Clear keepalive reference + // Join the request thread if it exists and is joinable { std::lock_guard lock(request_thread_mutex_); + if (request_thread_ && request_thread_->joinable()) { + // Release lock before joining to avoid holding mutex during join + std::unique_ptr thread_to_join = std::move(request_thread_); + request_thread_mutex_.unlock(); + thread_to_join->join(); + request_thread_mutex_.lock(); + } keepalive_.reset(); } } @@ -95,14 +102,17 @@ void CurlClient::do_run() { { std::lock_guard request_thread_guard(request_thread_mutex_); + // Join any previous thread before starting a new one + if (request_thread_ && request_thread_->joinable()) { + request_thread_->join(); + } + // Store a keepalive reference to ensure destructor doesn't run on request thread keepalive_ = shared_from_this(); // Capture only raw 'this' pointer, not shared_ptr request_thread_ = std::make_unique( [this]() { this->perform_request(); }); - - request_thread_->detach(); } } @@ -478,23 +488,13 @@ bool CurlClient::handle_redirect(long response_code, CURL* curl) { void CurlClient::perform_request() { // RAII guard to clear keepalive when function exits - // This ensures destructor never runs on this thread + // Since we join the thread before destroying the object, we can safely clear keepalive here struct KeepaliveGuard { CurlClient* client; explicit KeepaliveGuard(CurlClient* c) : client(c) {} ~KeepaliveGuard() { - if (!client->shutting_down_) { - // Post to executor so keepalive is cleared on executor thread - boost::asio::post(client->backoff_timer_.get_executor(), - [client = this->client]() { - std::lock_guard lock(client->request_thread_mutex_); - client->keepalive_.reset(); - }); - } else { - // During shutdown, clear immediately since executor may be gone - std::lock_guard lock(client->request_thread_mutex_); - client->keepalive_.reset(); - } + std::lock_guard lock(client->request_thread_mutex_); + client->keepalive_.reset(); } } guard(this); @@ -652,8 +652,28 @@ void CurlClient::do_shutdown(std::function completion) { shutting_down_ = true; backoff_timer_.cancel(); - // Note: CURL requests in progress will be aborted via the write callback - // returning 0 when shutting_down_ is true + // Close the socket to abort the CURL operation + curl_socket_t sock = curl_socket_.load(); + if (sock != CURL_SOCKET_BAD) { +#ifdef _WIN32 + closesocket(sock); +#else + close(sock); +#endif + } + + // Join the request thread if it exists and is joinable + { + std::lock_guard lock(request_thread_mutex_); + if (request_thread_ && request_thread_->joinable()) { + // Release lock before joining to avoid holding mutex during join + std::unique_ptr thread_to_join = std::move(request_thread_); + request_thread_mutex_.unlock(); + thread_to_join->join(); + request_thread_mutex_.lock(); + } + keepalive_.reset(); + } if (completion) { completion(); From 1c649876c3241db446ac5c2c666843ef4881422a Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 10 Oct 2025 16:30:14 -0700 Subject: [PATCH 28/90] Testing round 1. --- CMakeLists.txt | 16 + libs/server-sent-events/src/curl_client.cpp | 42 +- libs/server-sent-events/tests/CMakeLists.txt | 2 +- libs/server-sent-events/tests/README.md | 46 + .../tests/curl_client_test.cpp | 846 ++++++++++++++++++ .../tests/mock_sse_server.hpp | 464 ++++++++++ scripts/generate-coverage.sh | 55 ++ 7 files changed, 1449 insertions(+), 22 deletions(-) create mode 100644 libs/server-sent-events/tests/README.md create mode 100644 libs/server-sent-events/tests/curl_client_test.cpp create mode 100644 libs/server-sent-events/tests/mock_sse_server.hpp create mode 100755 scripts/generate-coverage.sh diff --git a/CMakeLists.txt b/CMakeLists.txt index f379e58ca..34c2e5775 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -64,6 +64,13 @@ cmake_dependent_option(LD_TESTING_SANITIZERS OFF # otherwise, off ) +cmake_dependent_option(LD_BUILD_COVERAGE + "Enable code coverage instrumentation for unit tests." + OFF # default to off since it requires specific build configuration + "LD_BUILD_UNIT_TESTS" # only expose if unit tests enabled + OFF # otherwise, off +) + cmake_dependent_option(LD_BUILD_CONTRACT_TESTS "Build contract test service." OFF # default to disabling contract tests, since they require running a service @@ -126,6 +133,15 @@ if (LD_BUILD_UNIT_TESTS) set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fsanitize=address -fsanitize=leak") endif () endif () + if (LD_BUILD_COVERAGE) + message(STATUS "LaunchDarkly: enabling code coverage") + if (CMAKE_CXX_COMPILER_ID STREQUAL "GNU" OR CMAKE_CXX_COMPILER_ID STREQUAL "Clang") + set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} --coverage -fprofile-arcs -ftest-coverage") + set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} --coverage") + else() + message(WARNING "Code coverage requested but compiler ${CMAKE_CXX_COMPILER_ID} is not supported. Coverage will not be enabled.") + endif() + endif() include(${CMAKE_FILES}/googletest.cmake) enable_testing() endif () diff --git a/libs/server-sent-events/src/curl_client.cpp b/libs/server-sent-events/src/curl_client.cpp index f92b74d5f..46914d980 100644 --- a/libs/server-sent-events/src/curl_client.cpp +++ b/libs/server-sent-events/src/curl_client.cpp @@ -37,27 +37,27 @@ CurlClient::CurlClient(boost::asio::any_io_executor executor, std::optional custom_ca_file, bool use_https) : - host_(std::move(host)), - port_(std::move(port)), - req_(std::move(req)), - connect_timeout_(connect_timeout), - read_timeout_(read_timeout), - write_timeout_(write_timeout), - event_receiver_(std::move(receiver)), - logger_(std::move(logger)), - errors_(std::move(errors)), - skip_verify_peer_(skip_verify_peer), - custom_ca_file_(std::move(custom_ca_file)), - use_https_(use_https), - backoff_(initial_reconnect_delay.value_or(kDefaultInitialReconnectDelay), - kDefaultMaxBackoffDelay), - last_event_id_(std::nullopt), - current_event_(std::nullopt), - shutting_down_(false), - curl_socket_(CURL_SOCKET_BAD), - buffered_line_(std::nullopt), - begin_CR_(false), - backoff_timer_(std::move(executor)) { + host_(std::move(host)), + port_(std::move(port)), + req_(std::move(req)), + connect_timeout_(connect_timeout), + read_timeout_(read_timeout), + write_timeout_(write_timeout), + event_receiver_(std::move(receiver)), + logger_(std::move(logger)), + errors_(std::move(errors)), + skip_verify_peer_(skip_verify_peer), + custom_ca_file_(std::move(custom_ca_file)), + use_https_(use_https), + backoff_(initial_reconnect_delay.value_or(kDefaultInitialReconnectDelay), + kDefaultMaxBackoffDelay), + last_event_id_(std::nullopt), + current_event_(std::nullopt), + shutting_down_(false), + curl_socket_(CURL_SOCKET_BAD), + buffered_line_(std::nullopt), + begin_CR_(false), last_download_amount_(0), + backoff_timer_(std::move(executor)) { } CurlClient::~CurlClient() { diff --git a/libs/server-sent-events/tests/CMakeLists.txt b/libs/server-sent-events/tests/CMakeLists.txt index eb20d404f..270231a9d 100644 --- a/libs/server-sent-events/tests/CMakeLists.txt +++ b/libs/server-sent-events/tests/CMakeLists.txt @@ -16,6 +16,6 @@ endif () add_executable(gtest_${LIBNAME} ${tests}) -target_link_libraries(gtest_${LIBNAME} launchdarkly::sse foxy GTest::gtest_main) +target_link_libraries(gtest_${LIBNAME} launchdarkly::sse foxy GTest::gtest_main GTest::gmock) gtest_discover_tests(gtest_${LIBNAME}) diff --git a/libs/server-sent-events/tests/README.md b/libs/server-sent-events/tests/README.md new file mode 100644 index 000000000..0b1796f22 --- /dev/null +++ b/libs/server-sent-events/tests/README.md @@ -0,0 +1,46 @@ +# Server-Sent Events Client Tests + +This directory contains comprehensive unit tests for the SSE client implementation. + +## Building with Code Coverage + +To build the tests with code coverage instrumentation: + +```bash +# Configure with coverage enabled (note: sanitizers must be disabled) +cmake -B build -DLD_BUILD_UNIT_TESTS=ON -DLD_BUILD_COVERAGE=ON -DLD_TESTING_SANITIZERS=OFF + +# Build the tests +cmake --build build --target gtest_launchdarkly-sse-client + +# Run the tests +cd build && ctest --output-on-failure + +# Generate coverage report +../scripts/generate-coverage.sh + +# View the report +xdg-open build/coverage/html/index.html +``` + +## Test Structure + +- `backoff_test.cpp` - Tests for backoff/retry logic +- `curl_client_test.cpp` - Comprehensive tests for CurlClient SSE implementation +- `mock_sse_server.hpp` - Mock SSE server for testing + +## Test Scenarios + +The tests cover: + +1. **Connection Management** - HTTP/HTTPS connections, timeouts, lifecycle +2. **SSE Parsing** - All SSE event formats, line endings, chunked data +3. **TLS/SSL** - Certificate verification, custom CA files +4. **HTTP Semantics** - Methods, headers, redirects, status codes +5. **Error Handling** - Network errors, malformed data, edge cases +6. **Resource Management** - No memory/thread/socket leaks +7. **Concurrency** - Thread safety, proper synchronization + +## Coverage Goals + +Target: >90% line and branch coverage for CurlClient implementation. diff --git a/libs/server-sent-events/tests/curl_client_test.cpp b/libs/server-sent-events/tests/curl_client_test.cpp new file mode 100644 index 000000000..02d470708 --- /dev/null +++ b/libs/server-sent-events/tests/curl_client_test.cpp @@ -0,0 +1,846 @@ +#include +#include + +#include + +#include "mock_sse_server.hpp" + +#include +#include + +#include +#include +#include +#include +#include + +using namespace launchdarkly::sse; +using namespace launchdarkly::sse::test; +using namespace std::chrono_literals; + +namespace { + +// C++17-compatible latch replacement +class SimpleLatch { +public: + explicit SimpleLatch(std::size_t count) : count_(count) {} + + void count_down() { + std::lock_guard lock(mutex_); + if (count_ > 0) { + --count_; + } + cv_.notify_all(); + } + + template + bool wait_for(std::chrono::duration timeout) { + std::unique_lock lock(mutex_); + return cv_.wait_for(lock, timeout, [this] { return count_ == 0; }); + } + +private: + std::mutex mutex_; + std::condition_variable cv_; + std::size_t count_; +}; + +// Helper to synchronize event reception in tests +class EventCollector { +public: + void add_event(Event event) { + std::lock_guard lock(mutex_); + events_.push_back(std::move(event)); + cv_.notify_all(); + } + + void add_error(Error error) { + std::lock_guard lock(mutex_); + errors_.push_back(std::move(error)); + cv_.notify_all(); + } + + bool wait_for_events(size_t count, std::chrono::milliseconds timeout = 5000ms) { + std::unique_lock lock(mutex_); + return cv_.wait_for(lock, timeout, [&] { return events_.size() >= count; }); + } + + bool wait_for_errors(size_t count, std::chrono::milliseconds timeout = 5000ms) { + std::unique_lock lock(mutex_); + return cv_.wait_for(lock, timeout, [&] { return errors_.size() >= count; }); + } + + std::vector events() const { + std::lock_guard lock(mutex_); + return events_; + } + + std::vector errors() const { + std::lock_guard lock(mutex_); + return errors_; + } + + void clear() { + std::lock_guard lock(mutex_); + events_.clear(); + errors_.clear(); + } + +private: + mutable std::mutex mutex_; + std::condition_variable cv_; + std::vector events_; + std::vector errors_; +}; + +// Helper to run io_context in background thread +class IoContextRunner { +public: + IoContextRunner() : work_guard_(boost::asio::make_work_guard(ioc_)) { + thread_ = std::thread([this] { ioc_.run(); }); + } + + ~IoContextRunner() { + work_guard_.reset(); + ioc_.stop(); + if (thread_.joinable()) { + thread_.join(); + } + } + + boost::asio::io_context& context() { return ioc_; } + +private: + boost::asio::io_context ioc_; + boost::asio::executor_work_guard work_guard_; + std::thread thread_; +}; + +} // namespace + +// Basic connectivity tests + +TEST(CurlClientTest, ConnectsToHttpServer) { + MockSSEServer server; + auto port = server.start(TestHandlers::simple_event("hello world")); + + // Give server a moment to start accepting connections + std::this_thread::sleep_for(100ms); + + IoContextRunner runner; + EventCollector collector; + + auto client = Builder(runner.context().get_executor(), "http://localhost:" + std::to_string(port)) + .receiver([&](Event e) { collector.add_event(std::move(e)); }) + .use_curl(true) + .build(); + + client->async_connect(); + + ASSERT_TRUE(collector.wait_for_events(1)); + auto events = collector.events(); + ASSERT_EQ(1, events.size()); + EXPECT_EQ("hello world", events[0].data()); + + SimpleLatch shutdown_latch(1); + client->async_shutdown([&] { shutdown_latch.count_down(); }); + EXPECT_TRUE(shutdown_latch.wait_for(5000ms)); +} + +TEST(CurlClientTest, HandlesMultipleEvents) { + MockSSEServer server; + auto port = server.start(TestHandlers::multiple_events({"event1", "event2", "event3"})); + + IoContextRunner runner; + EventCollector collector; + + auto client = Builder(runner.context().get_executor(), "http://localhost:" + std::to_string(port)) + .receiver([&](Event e) { collector.add_event(std::move(e)); }) + .use_curl(true) + .build(); + + client->async_connect(); + + ASSERT_TRUE(collector.wait_for_events(3)); + auto events = collector.events(); + ASSERT_EQ(3, events.size()); + EXPECT_EQ("event1", events[0].data()); + EXPECT_EQ("event2", events[1].data()); + EXPECT_EQ("event3", events[2].data()); + + SimpleLatch shutdown_latch(1); + client->async_shutdown([&] { shutdown_latch.count_down(); }); + EXPECT_TRUE(shutdown_latch.wait_for(5000ms)); +} + +// SSE parsing tests + +TEST(CurlClientTest, ParsesEventWithType) { + MockSSEServer server; + auto port = server.start([](auto const&, auto send_response, auto send_sse_event, auto close) { + http::response res{http::status::ok, 11}; + res.set(http::field::content_type, "text/event-stream"); + res.chunked(true); + send_response(res); + + send_sse_event(SSEFormatter::event("test data", "custom-type")); + std::this_thread::sleep_for(10ms); + close(); + }); + + IoContextRunner runner; + EventCollector collector; + + auto client = Builder(runner.context().get_executor(), "http://localhost:" + std::to_string(port)) + .receiver([&](Event e) { collector.add_event(std::move(e)); }) + .use_curl(true) + .build(); + + client->async_connect(); + + ASSERT_TRUE(collector.wait_for_events(1)); + auto events = collector.events(); + ASSERT_EQ(1, events.size()); + EXPECT_EQ("test data", events[0].data()); + EXPECT_EQ("custom-type", events[0].type()); + + SimpleLatch shutdown_latch(1); + client->async_shutdown([&] { shutdown_latch.count_down(); }); + EXPECT_TRUE(shutdown_latch.wait_for(5000ms)); +} + +TEST(CurlClientTest, ParsesEventWithId) { + MockSSEServer server; + auto port = server.start([](auto const&, auto send_response, auto send_sse_event, auto close) { + http::response res{http::status::ok, 11}; + res.set(http::field::content_type, "text/event-stream"); + res.chunked(true); + send_response(res); + + send_sse_event(SSEFormatter::event("test data", "", "event-123")); + std::this_thread::sleep_for(10ms); + close(); + }); + + IoContextRunner runner; + EventCollector collector; + + auto client = Builder(runner.context().get_executor(), "http://localhost:" + std::to_string(port)) + .receiver([&](Event e) { collector.add_event(std::move(e)); }) + .use_curl(true) + .build(); + + client->async_connect(); + + ASSERT_TRUE(collector.wait_for_events(1)); + auto events = collector.events(); + ASSERT_EQ(1, events.size()); + EXPECT_EQ("test data", events[0].data()); + EXPECT_EQ("event-123", events[0].id()); + + SimpleLatch shutdown_latch(1); + client->async_shutdown([&] { shutdown_latch.count_down(); }); + EXPECT_TRUE(shutdown_latch.wait_for(5000ms)); +} + +TEST(CurlClientTest, ParsesMultiLineData) { + MockSSEServer server; + auto port = server.start([](auto const&, auto send_response, auto send_sse_event, auto close) { + http::response res{http::status::ok, 11}; + res.set(http::field::content_type, "text/event-stream"); + res.chunked(true); + send_response(res); + + send_sse_event(SSEFormatter::event("line1\nline2\nline3")); + std::this_thread::sleep_for(10ms); + close(); + }); + + IoContextRunner runner; + EventCollector collector; + + auto client = Builder(runner.context().get_executor(), "http://localhost:" + std::to_string(port)) + .receiver([&](Event e) { collector.add_event(std::move(e)); }) + .use_curl(true) + .build(); + + client->async_connect(); + + ASSERT_TRUE(collector.wait_for_events(1)); + auto events = collector.events(); + ASSERT_EQ(1, events.size()); + EXPECT_EQ("line1\nline2\nline3", events[0].data()); + + SimpleLatch shutdown_latch(1); + client->async_shutdown([&] { shutdown_latch.count_down(); }); + EXPECT_TRUE(shutdown_latch.wait_for(5000ms)); +} + +TEST(CurlClientTest, HandlesComments) { + GTEST_SKIP() << "Comment filtering is not yet implemented in the SSE parser"; + + MockSSEServer server; + auto port = server.start([](auto const&, auto send_response, auto send_sse_event, auto close) { + http::response res{http::status::ok, 11}; + res.set(http::field::content_type, "text/event-stream"); + res.chunked(true); + send_response(res); + + // Send a comment (should be ignored) + send_sse_event(SSEFormatter::comment("this is a comment")); + // Send an actual event + send_sse_event(SSEFormatter::event("real data")); + std::this_thread::sleep_for(10ms); + close(); + }); + + IoContextRunner runner; + EventCollector collector; + + auto client = Builder(runner.context().get_executor(), "http://localhost:" + std::to_string(port)) + .receiver([&](Event e) { collector.add_event(std::move(e)); }) + .use_curl(true) + .build(); + + client->async_connect(); + + ASSERT_TRUE(collector.wait_for_events(1)); + auto events = collector.events(); + // Should only receive the real event, not the comment + ASSERT_EQ(1, events.size()); + EXPECT_EQ("real data", events[0].data()); + + SimpleLatch shutdown_latch(1); + client->async_shutdown([&] { shutdown_latch.count_down(); }); + EXPECT_TRUE(shutdown_latch.wait_for(5000ms)); +} + +// HTTP method tests + +TEST(CurlClientTest, SupportsPostMethod) { + MockSSEServer server; + std::string received_method; + + auto port = server.start([&](auto const& req, auto send_response, auto send_sse_event, auto close) { + received_method = std::string(req.method_string()); + + http::response res{http::status::ok, 11}; + res.set(http::field::content_type, "text/event-stream"); + res.chunked(true); + send_response(res); + + send_sse_event(SSEFormatter::event("response")); + std::this_thread::sleep_for(10ms); + close(); + }); + + IoContextRunner runner; + EventCollector collector; + + auto client = Builder(runner.context().get_executor(), "http://localhost:" + std::to_string(port)) + .receiver([&](Event e) { collector.add_event(std::move(e)); }) + .method(http::verb::post) + .body("test body") + .use_curl(true) + .build(); + + client->async_connect(); + + ASSERT_TRUE(collector.wait_for_events(1)); + EXPECT_EQ("POST", received_method); + + SimpleLatch shutdown_latch(1); + client->async_shutdown([&] { shutdown_latch.count_down(); }); + EXPECT_TRUE(shutdown_latch.wait_for(5000ms)); +} + +TEST(CurlClientTest, SupportsReportMethod) { + MockSSEServer server; + std::string received_method; + + auto port = server.start([&](auto const& req, auto send_response, auto send_sse_event, auto close) { + received_method = std::string(req.method_string()); + + http::response res{http::status::ok, 11}; + res.set(http::field::content_type, "text/event-stream"); + res.chunked(true); + send_response(res); + + send_sse_event(SSEFormatter::event("response")); + std::this_thread::sleep_for(10ms); + close(); + }); + + IoContextRunner runner; + EventCollector collector; + + auto client = Builder(runner.context().get_executor(), "http://localhost:" + std::to_string(port)) + .receiver([&](Event e) { collector.add_event(std::move(e)); }) + .method(http::verb::report) + .body("test body") + .use_curl(true) + .build(); + + client->async_connect(); + + ASSERT_TRUE(collector.wait_for_events(1)); + EXPECT_EQ("REPORT", received_method); + + SimpleLatch shutdown_latch(1); + client->async_shutdown([&] { shutdown_latch.count_down(); }); + EXPECT_TRUE(shutdown_latch.wait_for(5000ms)); +} + +// HTTP header tests + +TEST(CurlClientTest, SendsCustomHeaders) { + MockSSEServer server; + std::string custom_header_value; + + auto port = server.start([&](auto const& req, auto send_response, auto send_sse_event, auto close) { + auto it = req.find("X-Custom-Header"); + if (it != req.end()) { + custom_header_value = std::string(it->value()); + } + + http::response res{http::status::ok, 11}; + res.set(http::field::content_type, "text/event-stream"); + res.chunked(true); + send_response(res); + + send_sse_event(SSEFormatter::event("response")); + std::this_thread::sleep_for(10ms); + close(); + }); + + IoContextRunner runner; + EventCollector collector; + + auto client = Builder(runner.context().get_executor(), "http://localhost:" + std::to_string(port)) + .receiver([&](Event e) { collector.add_event(std::move(e)); }) + .header("X-Custom-Header", "custom-value") + .use_curl(true) + .build(); + + client->async_connect(); + + ASSERT_TRUE(collector.wait_for_events(1)); + EXPECT_EQ("custom-value", custom_header_value); + + SimpleLatch shutdown_latch(1); + client->async_shutdown([&] { shutdown_latch.count_down(); }); + EXPECT_TRUE(shutdown_latch.wait_for(5000ms)); +} + +// HTTP status code tests + +TEST(CurlClientTest, Handles404Error) { + MockSSEServer server; + auto port = server.start(TestHandlers::http_error(http::status::not_found)); + + IoContextRunner runner; + EventCollector collector; + + auto client = Builder(runner.context().get_executor(), "http://localhost:" + std::to_string(port)) + .receiver([&](Event e) { collector.add_event(std::move(e)); }) + .errors([&](Error e) { collector.add_error(std::move(e)); }) + .use_curl(true) + .build(); + + client->async_connect(); + + ASSERT_TRUE(collector.wait_for_errors(1)); + auto errors = collector.errors(); + ASSERT_GE(errors.size(), 1); + + SimpleLatch shutdown_latch(1); + client->async_shutdown([&] { shutdown_latch.count_down(); }); + EXPECT_TRUE(shutdown_latch.wait_for(5000ms)); +} + +TEST(CurlClientTest, Handles500Error) { + // 500 errors are treated as transient server errors and should trigger + // backoff/retry behavior, not error callbacks. This is correct SSE client behavior. + std::atomic connection_attempts{0}; + + auto handler = [&](auto const&, auto send_response, auto, auto) { + connection_attempts++; + http::response res{http::status::internal_server_error, 11}; + res.body() = "Error"; + res.prepare_payload(); + send_response(res); + }; + + MockSSEServer server; + auto port = server.start(handler); + + IoContextRunner runner; + EventCollector collector; + + auto client = Builder(runner.context().get_executor(), "http://localhost:" + std::to_string(port)) + .receiver([&](Event e) { collector.add_event(std::move(e)); }) + .errors([&](Error e) { collector.add_error(std::move(e)); }) + .initial_reconnect_delay(50ms) // Short delay for test + .use_curl(true) + .build(); + + client->async_connect(); + + // Should NOT receive error callbacks - should retry instead + // Wait a bit to let multiple reconnection attempts happen + std::this_thread::sleep_for(300ms); + + // Verify that multiple reconnection attempts occurred (backoff/retry behavior) + EXPECT_GE(connection_attempts.load(), 2); + + // Verify no error callbacks were invoked (5xx are not reported as errors) + EXPECT_EQ(0, collector.errors().size()); + + SimpleLatch shutdown_latch(1); + client->async_shutdown([&] { shutdown_latch.count_down(); }); + EXPECT_TRUE(shutdown_latch.wait_for(5000ms)); +} + +// Redirect tests + +TEST(CurlClientTest, FollowsRedirects) { + MockSSEServer redirect_server; + MockSSEServer target_server; + + auto target_port = target_server.start(TestHandlers::simple_event("redirected")); + auto redirect_port = redirect_server.start( + TestHandlers::redirect("http://localhost:" + std::to_string(target_port) + "/") + ); + + IoContextRunner runner; + EventCollector collector; + + auto client = Builder(runner.context().get_executor(), "http://localhost:" + std::to_string(redirect_port)) + .receiver([&](Event e) { collector.add_event(std::move(e)); }) + .use_curl(true) + .build(); + + client->async_connect(); + + ASSERT_TRUE(collector.wait_for_events(1)); + auto events = collector.events(); + ASSERT_EQ(1, events.size()); + EXPECT_EQ("redirected", events[0].data()); + + SimpleLatch shutdown_latch(1); + client->async_shutdown([&] { shutdown_latch.count_down(); }); + EXPECT_TRUE(shutdown_latch.wait_for(5000ms)); +} + +// Connection lifecycle tests + +TEST(CurlClientTest, ShutdownStopsClient) { + MockSSEServer server; + auto port = server.start([](auto const&, auto send_response, auto send_sse_event, auto) { + http::response res{http::status::ok, 11}; + res.set(http::field::content_type, "text/event-stream"); + res.chunked(true); + send_response(res); + + // Keep sending events forever (until connection closes) + for (int i = 0; i < 1000; i++) { + send_sse_event(SSEFormatter::event("event " + std::to_string(i))); + std::this_thread::sleep_for(10ms); + } + }); + + IoContextRunner runner; + EventCollector collector; + + auto client = Builder(runner.context().get_executor(), "http://localhost:" + std::to_string(port)) + .receiver([&](Event e) { collector.add_event(std::move(e)); }) + .use_curl(true) + .build(); + + client->async_connect(); + + // Wait for at least one event + ASSERT_TRUE(collector.wait_for_events(1)); + + // Shutdown should complete quickly + auto shutdown_start = std::chrono::steady_clock::now(); + SimpleLatch shutdown_latch(1); + client->async_shutdown([&] { shutdown_latch.count_down(); }); + EXPECT_TRUE(shutdown_latch.wait_for(5000ms)); + auto shutdown_duration = std::chrono::steady_clock::now() - shutdown_start; + + // Shutdown should complete in reasonable time (less than 2 seconds) + EXPECT_LT(shutdown_duration, 2000ms); +} + +TEST(CurlClientTest, CanShutdownBeforeConnection) { + MockSSEServer server; + auto port = server.start(TestHandlers::simple_event("test")); + + IoContextRunner runner; + EventCollector collector; + + auto client = Builder(runner.context().get_executor(), "http://localhost:" + std::to_string(port)) + .receiver([&](Event e) { collector.add_event(std::move(e)); }) + .use_curl(true) + .build(); + + // Shutdown immediately without connecting + SimpleLatch shutdown_latch(1); + client->async_shutdown([&] { shutdown_latch.count_down(); }); + EXPECT_TRUE(shutdown_latch.wait_for(5000ms)); +} + +TEST(CurlClientTest, HandlesImmediateClose) { + // Immediate connection close is treated as a transient network error and should trigger + // backoff/retry behavior, not error callbacks. This is correct SSE client behavior. + std::atomic connection_attempts{0}; + + auto handler = [&](auto const&, auto, auto, auto close) { + connection_attempts++; + close(); // Immediately close without sending headers + }; + + MockSSEServer server; + auto port = server.start(handler); + + IoContextRunner runner; + EventCollector collector; + + auto client = Builder(runner.context().get_executor(), "http://localhost:" + std::to_string(port)) + .receiver([&](Event e) { collector.add_event(std::move(e)); }) + .errors([&](Error e) { collector.add_error(std::move(e)); }) + .initial_reconnect_delay(50ms) // Short delay for test + .use_curl(true) + .build(); + + client->async_connect(); + + // Should NOT receive error callbacks - should retry instead + // Wait a bit to let multiple reconnection attempts happen + std::this_thread::sleep_for(300ms); + + // Verify that multiple reconnection attempts occurred (backoff/retry behavior) + EXPECT_GE(connection_attempts.load(), 2); + + // Verify no error callbacks were invoked (connection errors trigger retry) + EXPECT_EQ(0, collector.errors().size()); + + SimpleLatch shutdown_latch(1); + client->async_shutdown([&] { shutdown_latch.count_down(); }); + EXPECT_TRUE(shutdown_latch.wait_for(5000ms)); +} + +// Timeout tests + +TEST(CurlClientTest, RespectsReadTimeout) { + MockSSEServer server; + auto port = server.start([](auto const&, auto send_response, auto send_sse_event, auto) { + http::response res{http::status::ok, 11}; + res.set(http::field::content_type, "text/event-stream"); + res.chunked(true); + send_response(res); + + // Send one event + send_sse_event(SSEFormatter::event("first")); + + // Then wait longer than read timeout without sending anything + std::this_thread::sleep_for(5000ms); + }); + + IoContextRunner runner; + EventCollector collector; + + auto client = Builder(runner.context().get_executor(), "http://localhost:" + std::to_string(port)) + .receiver([&](Event e) { collector.add_event(std::move(e)); }) + .errors([&](Error e) { collector.add_error(std::move(e)); }) + .read_timeout(500ms) // Short timeout for test + .initial_reconnect_delay(50ms) + .use_curl(true) + .build(); + + client->async_connect(); + + // Should receive the first event + ASSERT_TRUE(collector.wait_for_events(1, 2000ms)); + + // Then should get a timeout error + ASSERT_TRUE(collector.wait_for_errors(1, 3000ms)); + + SimpleLatch shutdown_latch(1); + client->async_shutdown([&] { shutdown_latch.count_down(); }); + EXPECT_TRUE(shutdown_latch.wait_for(5000ms)); +} + +// Resource management tests + +TEST(CurlClientTest, NoThreadLeaksAfterMultipleConnections) { + // This test verifies that threads are properly joined and not leaked + MockSSEServer server; + auto port = server.start(TestHandlers::simple_event("test")); + + IoContextRunner runner; + + // Create and destroy multiple clients + for (int i = 0; i < 5; i++) { + EventCollector collector; + + auto client = Builder(runner.context().get_executor(), "http://localhost:" + std::to_string(port)) + .receiver([&](Event e) { collector.add_event(std::move(e)); }) + .use_curl(true) + .build(); + + client->async_connect(); + ASSERT_TRUE(collector.wait_for_events(1)); + + SimpleLatch shutdown_latch(1); + client->async_shutdown([&] { shutdown_latch.count_down(); }); + EXPECT_TRUE(shutdown_latch.wait_for(5000ms)); + + // Client should be cleanly destroyed here + } + + // If threads weren't properly joined, we'd likely see issues here + // The test passing indicates proper resource cleanup +} + +TEST(CurlClientTest, DestructorCleansUpProperly) { + MockSSEServer server; + auto port = server.start([](auto const&, auto send_response, auto send_sse_event, auto) { + http::response res{http::status::ok, 11}; + res.set(http::field::content_type, "text/event-stream"); + res.chunked(true); + send_response(res); + + // Keep sending events + for (int i = 0; i < 100; i++) { + send_sse_event(SSEFormatter::event("event " + std::to_string(i))); + std::this_thread::sleep_for(10ms); + } + }); + + IoContextRunner runner; + EventCollector collector; + + { + auto client = Builder(runner.context().get_executor(), "http://localhost:" + std::to_string(port)) + .receiver([&](Event e) { collector.add_event(std::move(e)); }) + .use_curl(true) + .build(); + + client->async_connect(); + ASSERT_TRUE(collector.wait_for_events(1)); + + // Let destructor run without explicit shutdown + } + + // If destructor doesn't properly clean up, this could hang or crash + // Test passing indicates proper cleanup in destructor +} + +// Edge case tests + +TEST(CurlClientTest, HandlesEmptyEventData) { + MockSSEServer server; + auto port = server.start([](auto const&, auto send_response, auto send_sse_event, auto close) { + http::response res{http::status::ok, 11}; + res.set(http::field::content_type, "text/event-stream"); + res.chunked(true); + send_response(res); + + send_sse_event(SSEFormatter::event("")); + std::this_thread::sleep_for(10ms); + close(); + }); + + IoContextRunner runner; + EventCollector collector; + + auto client = Builder(runner.context().get_executor(), "http://localhost:" + std::to_string(port)) + .receiver([&](Event e) { collector.add_event(std::move(e)); }) + .use_curl(true) + .build(); + + client->async_connect(); + + ASSERT_TRUE(collector.wait_for_events(1)); + auto events = collector.events(); + ASSERT_EQ(1, events.size()); + EXPECT_EQ("", events[0].data()); + + SimpleLatch shutdown_latch(1); + client->async_shutdown([&] { shutdown_latch.count_down(); }); + EXPECT_TRUE(shutdown_latch.wait_for(5000ms)); +} + +TEST(CurlClientTest, HandlesEventWithOnlyType) { + MockSSEServer server; + auto port = server.start([](auto const&, auto send_response, auto send_sse_event, auto close) { + http::response res{http::status::ok, 11}; + res.set(http::field::content_type, "text/event-stream"); + res.chunked(true); + send_response(res); + + // Send event with type but empty data + send_sse_event("event: heartbeat\ndata: \n\n"); + std::this_thread::sleep_for(10ms); + close(); + }); + + IoContextRunner runner; + EventCollector collector; + + auto client = Builder(runner.context().get_executor(), "http://localhost:" + std::to_string(port)) + .receiver([&](Event e) { collector.add_event(std::move(e)); }) + .use_curl(true) + .build(); + + client->async_connect(); + + ASSERT_TRUE(collector.wait_for_events(1)); + auto events = collector.events(); + ASSERT_EQ(1, events.size()); + EXPECT_EQ("heartbeat", events[0].type()); + EXPECT_EQ("", events[0].data()); + + SimpleLatch shutdown_latch(1); + client->async_shutdown([&] { shutdown_latch.count_down(); }); + EXPECT_TRUE(shutdown_latch.wait_for(5000ms)); +} + +TEST(CurlClientTest, HandlesRapidEvents) { + MockSSEServer server; + const int num_events = 100; + + auto port = server.start([](auto const&, auto send_response, auto send_sse_event, auto close) { + http::response res{http::status::ok, 11}; + res.set(http::field::content_type, "text/event-stream"); + res.chunked(true); + send_response(res); + + // Send many events rapidly + for (int i = 0; i < num_events; i++) { + send_sse_event(SSEFormatter::event("event" + std::to_string(i))); + } + std::this_thread::sleep_for(10ms); + close(); + }); + + IoContextRunner runner; + EventCollector collector; + + auto client = Builder(runner.context().get_executor(), "http://localhost:" + std::to_string(port)) + .receiver([&](Event e) { collector.add_event(std::move(e)); }) + .use_curl(true) + .build(); + + client->async_connect(); + + ASSERT_TRUE(collector.wait_for_events(num_events, 10000ms)); + auto events = collector.events(); + EXPECT_EQ(num_events, events.size()); + + SimpleLatch shutdown_latch(1); + client->async_shutdown([&] { shutdown_latch.count_down(); }); + EXPECT_TRUE(shutdown_latch.wait_for(5000ms)); +} diff --git a/libs/server-sent-events/tests/mock_sse_server.hpp b/libs/server-sent-events/tests/mock_sse_server.hpp new file mode 100644 index 000000000..d07da5566 --- /dev/null +++ b/libs/server-sent-events/tests/mock_sse_server.hpp @@ -0,0 +1,464 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace launchdarkly::sse::test { + +namespace beast = boost::beast; +namespace http = beast::http; +namespace net = boost::asio; +using tcp = net::ip::tcp; +namespace ssl = boost::asio::ssl; + +/** + * Mock SSE server for testing. Supports both HTTP and HTTPS. + * Can be configured to send various SSE payloads, error responses, timeouts, etc. + */ +class MockSSEServer { +public: + using RequestHandler = std::function const& req, + std::function)> send_response, + std::function send_sse_event, + std::function close_connection + )>; + + MockSSEServer() + : ioc_(), + acceptor_(ioc_), + running_(false), + port_(0) { + } + + ~MockSSEServer() { + stop(); + } + + /** + * Start the server on a random available port. + * Returns the port number. + */ + uint16_t start(RequestHandler handler, bool use_ssl = false) { + std::cout << "[MockServer] start: initializing server" << std::endl; + handler_ = std::move(handler); + use_ssl_ = use_ssl; + + // Bind to port 0 to get a random available port + tcp::endpoint endpoint(tcp::v4(), 0); + acceptor_.open(endpoint.protocol()); + acceptor_.set_option(net::socket_base::reuse_address(true)); + acceptor_.bind(endpoint); + acceptor_.listen(); + + port_ = acceptor_.local_endpoint().port(); + running_ = true; + + std::cout << "[MockServer] Server bound to port " << port_ << std::endl; + + // Start accepting connections in a background thread + server_thread_ = std::thread([this]() { + std::cout << "[MockServer] Background thread started" << std::endl; + do_accept(); + std::cout << "[MockServer] About to run io_context" << std::endl; + ioc_.run(); + std::cout << "[MockServer] io_context.run() exited" << std::endl; + }); + + std::cout << "[MockServer] Server started on port " << port_ << std::endl; + return port_; + } + + void stop() { + if (!running_) { + return; + } + + running_ = false; + + boost::system::error_code ec; + acceptor_.close(ec); + + ioc_.stop(); + + if (server_thread_.joinable()) { + server_thread_.join(); + } + } + + uint16_t port() const { return port_; } + + std::string url() const { + return (use_ssl_ ? "https://" : "http://") + + std::string("localhost:") + std::to_string(port_); + } + +private: + void do_accept() { + if (!running_) { + std::cout << "[MockServer] do_accept: not running, returning" << std::endl; + return; + } + + std::cout << "[MockServer] do_accept: waiting for connection on port " << port_ << std::endl; + acceptor_.async_accept( + [this](boost::system::error_code ec, tcp::socket socket) { + if (ec) { + std::cout << "[MockServer] async_accept error: " << ec.message() << std::endl; + } else { + std::cout << "[MockServer] Connection accepted from " + << socket.remote_endpoint().address().to_string() + << ":" << socket.remote_endpoint().port() << std::endl; + handle_connection(std::move(socket)); + } + + if (running_) { + do_accept(); + } + }); + } + + void handle_connection(tcp::socket socket) { + std::cout << "[MockServer] handle_connection: creating Connection object" << std::endl; + auto conn = std::make_shared( + std::move(socket), handler_); + conn->start(); + std::cout << "[MockServer] handle_connection: Connection started" << std::endl; + } + + struct Connection : std::enable_shared_from_this { + tcp::socket socket_; + beast::flat_buffer buffer_; + http::request req_; + RequestHandler handler_; + std::atomic closed_; + + Connection(tcp::socket socket, RequestHandler handler) + : socket_(std::move(socket)), + handler_(std::move(handler)), + closed_(false) { + } + + void start() { + std::cout << "[Connection] start: beginning read" << std::endl; + do_read(); + } + + void do_read() { + std::cout << "[Connection] do_read: async_read started" << std::endl; + auto self = shared_from_this(); + http::async_read( + socket_, + buffer_, + req_, + [self](boost::system::error_code ec, std::size_t bytes) { + if (ec) { + std::cout << "[Connection] async_read error: " << ec.message() << std::endl; + return; + } + std::cout << "[Connection] async_read success: " << bytes << " bytes read" << std::endl; + std::cout << "[Connection] Request: " << self->req_.method_string() << " " + << self->req_.target() << std::endl; + self->handle_request(); + }); + } + + void handle_request() { + std::cout << "[Connection] handle_request: setting up callbacks" << std::endl; + auto self = shared_from_this(); + + auto send_response = [self](http::response res) { + std::cout << "[Connection] send_response called: status=" << res.result_int() + << ", chunked=" << res.chunked() << std::endl; + if (self->closed_) { + std::cout << "[Connection] send_response: already closed, skipping" << std::endl; + return; + } + + boost::system::error_code ec; + + // For error responses and redirects (with body or no SSE), write complete response + if (res.result() != http::status::ok || !res.chunked()) { + std::cout << "[Connection] Sending complete response" << std::endl; + http::write(self->socket_, res, ec); + if (ec) { + std::cout << "[Connection] Error writing response: " << ec.message() << std::endl; + } else { + std::cout << "[Connection] Complete response sent successfully" << std::endl; + } + return; + } + + // For SSE (chunked OK responses), manually send headers to keep connection open + std::cout << "[Connection] Sending SSE response headers" << std::endl; + std::ostringstream oss; + oss << "HTTP/1.1 " << res.result_int() << " " << res.reason() << "\r\n"; + for (auto const& field : res) { + oss << field.name_string() << ": " << field.value() << "\r\n"; + } + oss << "\r\n"; // End of headers + + std::string header_str = oss.str(); + std::cout << "[Connection] Headers to send:\n" << header_str << std::endl; + net::write(self->socket_, net::buffer(header_str), ec); + if (ec) { + std::cout << "[Connection] Error writing headers: " << ec.message() << std::endl; + } else { + std::cout << "[Connection] Headers sent successfully, " << header_str.size() << " bytes" << std::endl; + } + }; + + auto send_sse_event = [self](std::string const& data) { + std::cout << "[Connection] send_sse_event called: " << data.size() << " bytes" << std::endl; + if (self->closed_) { + std::cout << "[Connection] send_sse_event: already closed, skipping" << std::endl; + return; + } + + boost::system::error_code ec; + + // Send as chunked encoding: size in hex + CRLF + data + CRLF + std::ostringstream chunk; + chunk << std::hex << data.size() << "\r\n" << data << "\r\n"; + std::string chunk_str = chunk.str(); + + std::cout << "[Connection] Sending chunk: size=" << data.size() + << ", total chunk size=" << chunk_str.size() << " bytes" << std::endl; + + net::write(self->socket_, net::buffer(chunk_str), ec); + if (ec) { + std::cout << "[Connection] Error writing SSE data: " << ec.message() << std::endl; + } else { + std::cout << "[Connection] SSE chunk sent successfully" << std::endl; + } + }; + + auto close_connection = [self]() { + std::cout << "[Connection] close_connection called" << std::endl; + if (self->closed_) { + std::cout << "[Connection] Already closed" << std::endl; + return; + } + self->closed_ = true; + + // Send final chunk terminator for chunked encoding + boost::system::error_code ec; + std::string final_chunk = "0\r\n\r\n"; + std::cout << "[Connection] Sending final chunk terminator" << std::endl; + net::write(self->socket_, net::buffer(final_chunk), ec); + if (ec) { + std::cout << "[Connection] Error writing final chunk: " << ec.message() << std::endl; + } else { + std::cout << "[Connection] Final chunk sent" << std::endl; + } + + self->socket_.shutdown(tcp::socket::shutdown_both, ec); + if (ec) { + std::cout << "[Connection] Error during shutdown: " << ec.message() << std::endl; + } + self->socket_.close(ec); + if (ec) { + std::cout << "[Connection] Error during close: " << ec.message() << std::endl; + } else { + std::cout << "[Connection] Connection closed successfully" << std::endl; + } + }; + + std::cout << "[Connection] Calling handler" << std::endl; + handler_(req_, send_response, send_sse_event, close_connection); + std::cout << "[Connection] Handler returned" << std::endl; + } + }; + + net::io_context ioc_; + tcp::acceptor acceptor_; + std::thread server_thread_; + RequestHandler handler_; + std::atomic running_; + std::atomic port_; + bool use_ssl_; +}; + +/** + * Helper to send SSE-formatted events + */ +class SSEFormatter { +public: + static std::string event(std::string const& data, + std::string const& event_type = "", + std::string const& id = "") { + std::string result; + + if (!id.empty()) { + result += "id: " + id + "\n"; + } + + if (!event_type.empty()) { + result += "event: " + event_type + "\n"; + } + + // Handle multi-line data + if (data.empty()) { + // Empty data still needs at least one data field + result += "data: \n"; + } else { + size_t pos = 0; + size_t found; + while ((found = data.find('\n', pos)) != std::string::npos) { + result += "data: " + data.substr(pos, found - pos) + "\n"; + pos = found + 1; + } + if (pos < data.length()) { + result += "data: " + data.substr(pos) + "\n"; + } + } + + result += "\n"; + return result; + } + + static std::string comment(std::string const& text) { + return ": " + text + "\n"; + } +}; + +/** + * Common test handlers for typical scenarios + */ +class TestHandlers { +public: + /** + * Send a simple SSE stream with one event then close + */ + static MockSSEServer::RequestHandler simple_event(std::string data) { + return [data = std::move(data)]( + auto const&, auto send_response, auto send_sse_event, auto close) { + + http::response res{http::status::ok, 11}; + res.set(http::field::content_type, "text/event-stream"); + res.set(http::field::cache_control, "no-cache"); + res.keep_alive(true); + res.chunked(true); + + // Send response headers + send_response(res); + + // Send SSE event + send_sse_event(SSEFormatter::event(data)); + + // Close connection after a brief delay + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + close(); + }; + } + + /** + * Send multiple events + */ + static MockSSEServer::RequestHandler multiple_events(std::vector events) { + return [events = std::move(events)]( + auto const&, auto send_response, auto send_sse_event, auto close) { + + http::response res{http::status::ok, 11}; + res.set(http::field::content_type, "text/event-stream"); + res.keep_alive(true); + res.chunked(true); + + send_response(res); + + for (auto const& data : events) { + send_sse_event(SSEFormatter::event(data)); + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + } + + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + close(); + }; + } + + /** + * Return an HTTP error status + */ + static MockSSEServer::RequestHandler http_error(http::status status) { + return [status](auto const&, auto send_response, auto, auto) { + http::response res{status, 11}; + res.body() = "Error"; + res.prepare_payload(); + send_response(res); + }; + } + + /** + * Send a redirect + */ + static MockSSEServer::RequestHandler redirect(std::string location, http::status status = http::status::moved_permanently) { + return [location = std::move(location), status]( + auto const&, auto send_response, auto, auto) { + + http::response res{status, 11}; + res.set(http::field::location, location); + send_response(res); + }; + } + + /** + * Never respond (to test timeouts) + */ + static MockSSEServer::RequestHandler timeout() { + return [](auto const&, auto, auto, auto) { + // Do nothing - let the connection hang + std::this_thread::sleep_for(std::chrono::hours(1)); + }; + } + + /** + * Close connection immediately + */ + static MockSSEServer::RequestHandler immediate_close() { + return [](auto const&, auto, auto, auto close) { + close(); + }; + } + + /** + * Echo back the request details in SSE format (for testing headers, methods, etc.) + */ + static MockSSEServer::RequestHandler echo() { + return [](auto const& req, auto send_response, auto send_sse_event, auto close) { + http::response res{http::status::ok, 11}; + res.set(http::field::content_type, "text/event-stream"); + res.chunked(true); + + send_response(res); + + std::string info; + info += "method: " + std::string(req.method_string()) + "\n"; + info += "target: " + std::string(req.target()) + "\n"; + + for (auto const& field : req) { + info += std::string(field.name_string()) + ": " + + std::string(field.value()) + "\n"; + } + + if (!req.body().empty()) { + info += "body: " + req.body() + "\n"; + } + + send_sse_event(SSEFormatter::event(info)); + + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + close(); + }; + } +}; + +} // namespace launchdarkly::sse::test diff --git a/scripts/generate-coverage.sh b/scripts/generate-coverage.sh new file mode 100755 index 000000000..9be3a5bf7 --- /dev/null +++ b/scripts/generate-coverage.sh @@ -0,0 +1,55 @@ +#!/bin/bash + +# Script to generate code coverage reports for LaunchDarkly C++ SDK +# Requirements: lcov, genhtml + +set -e + +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" +BUILD_DIR="${BUILD_DIR:-${PROJECT_ROOT}/build}" +COVERAGE_DIR="${BUILD_DIR}/coverage" + +echo "Generating code coverage report..." +echo "Build directory: $BUILD_DIR" +echo "Coverage output directory: $COVERAGE_DIR" + +# Create coverage directory +mkdir -p "$COVERAGE_DIR" + +# Capture coverage data +echo "Capturing coverage data..." +lcov --capture \ + --directory "$BUILD_DIR" \ + --output-file "$COVERAGE_DIR/coverage.info" \ + --rc lcov_branch_coverage=1 \ + --ignore-errors mismatch,negative + +# Remove external dependencies from coverage +echo "Filtering coverage data..." +lcov --remove "$COVERAGE_DIR/coverage.info" \ + '*/build/_deps/*' \ + '*/vendor/*' \ + '*/tests/*' \ + '*/usr/*' \ + --output-file "$COVERAGE_DIR/coverage_filtered.info" \ + --rc lcov_branch_coverage=1 + +# Generate HTML report +echo "Generating HTML report..." +genhtml "$COVERAGE_DIR/coverage_filtered.info" \ + --output-directory "$COVERAGE_DIR/html" \ + --title "LaunchDarkly C++ SDK Coverage" \ + --legend \ + --show-details \ + --branch-coverage \ + --rc genhtml_branch_coverage=1 + +echo "" +echo "Coverage report generated successfully!" +echo "Open: $COVERAGE_DIR/html/index.html" +echo "" + +# Print summary +lcov --summary "$COVERAGE_DIR/coverage_filtered.info" \ + --rc lcov_branch_coverage=1 From 3da9112a5ae3ac3167cb508a85b039f8852b5b35 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 13 Oct 2025 08:58:07 -0700 Subject: [PATCH 29/90] Test robustness. --- .../tests/curl_client_test.cpp | 268 ++++++++++++++++++ .../tests/mock_sse_server.hpp | 132 ++++----- 2 files changed, 319 insertions(+), 81 deletions(-) diff --git a/libs/server-sent-events/tests/curl_client_test.cpp b/libs/server-sent-events/tests/curl_client_test.cpp index 02d470708..4a4d8b6db 100644 --- a/libs/server-sent-events/tests/curl_client_test.cpp +++ b/libs/server-sent-events/tests/curl_client_test.cpp @@ -844,3 +844,271 @@ TEST(CurlClientTest, HandlesRapidEvents) { client->async_shutdown([&] { shutdown_latch.count_down(); }); EXPECT_TRUE(shutdown_latch.wait_for(5000ms)); } + +// Shutdown-specific tests - critical for preventing crashes/hangs in user applications + +TEST(CurlClientTest, ShutdownDuringBackoffDelay) { + // Tests curl_client.cpp:138 - on_backoff checks shutting_down_ + // This ensures clean shutdown during backoff/retry wait period + std::atomic connection_attempts{0}; + + auto handler = [&](auto const&, auto send_response, auto, auto) { + connection_attempts++; + // Return 500 to trigger backoff + http::response res{http::status::internal_server_error, 11}; + res.body() = "Error"; + res.prepare_payload(); + send_response(res); + }; + + MockSSEServer server; + auto port = server.start(handler); + + IoContextRunner runner; + EventCollector collector; + + auto client = Builder(runner.context().get_executor(), "http://localhost:" + std::to_string(port)) + .receiver([&](Event e) { collector.add_event(std::move(e)); }) + .initial_reconnect_delay(2000ms) // Long delay to ensure we shutdown during wait + .use_curl(true) + .build(); + + client->async_connect(); + + // Wait for first connection attempt to complete + std::this_thread::sleep_for(200ms); + EXPECT_GE(connection_attempts.load(), 1); + + // Now shutdown while it's waiting in backoff + auto shutdown_start = std::chrono::steady_clock::now(); + SimpleLatch shutdown_latch(1); + client->async_shutdown([&] { shutdown_latch.count_down(); }); + EXPECT_TRUE(shutdown_latch.wait_for(5000ms)); + auto shutdown_duration = std::chrono::steady_clock::now() - shutdown_start; + + // Shutdown should complete quickly despite long backoff delay + EXPECT_LT(shutdown_duration, 1000ms); + + // Should NOT have made another connection attempt during backoff + EXPECT_EQ(1, connection_attempts.load()); +} + +TEST(CurlClientTest, ShutdownDuringDataReception) { + // Tests curl_client.cpp:235 - WriteCallback checks shutting_down_ + // This covers the branch where we abort during SSE data parsing + SimpleLatch server_sending(1); + SimpleLatch client_received_some(1); + + auto handler = [&](auto const&, auto send_response, auto send_sse_event, auto) { + http::response res{http::status::ok, 11}; + res.set(http::field::content_type, "text/event-stream"); + res.chunked(true); + send_response(res); + + // Send events continuously + for (int i = 0; i < 100; i++) { + if (!send_sse_event(SSEFormatter::event("event " + std::to_string(i)))) { + return; // Connection closed or error - stop sending + } + if (i == 2) { + server_sending.count_down(); + } + std::this_thread::sleep_for(10ms); // Slow enough to allow shutdown mid-stream + } + }; + + MockSSEServer server; + auto port = server.start(handler); + + IoContextRunner runner; + EventCollector collector; + + auto client = Builder(runner.context().get_executor(), "http://localhost:" + std::to_string(port)) + .receiver([&](Event e) { + collector.add_event(std::move(e)); + if (collector.events().size() >= 2) { + client_received_some.count_down(); + } + }) + .use_curl(true) + .build(); + + client->async_connect(); + + // Wait until server is sending and client has received some events + ASSERT_TRUE(server_sending.wait_for(5000ms)); + ASSERT_TRUE(client_received_some.wait_for(5000ms)); + + // Shutdown while WriteCallback is actively processing data + auto shutdown_start = std::chrono::steady_clock::now(); + SimpleLatch shutdown_latch(1); + client->async_shutdown([&] { shutdown_latch.count_down(); }); + EXPECT_TRUE(shutdown_latch.wait_for(5000ms)); + auto shutdown_duration = std::chrono::steady_clock::now() - shutdown_start; + + // Shutdown should complete quickly even during active data transfer + EXPECT_LT(shutdown_duration, 2000ms); +} + +TEST(CurlClientTest, ShutdownDuringProgressCallback) { + // Tests curl_client.cpp:188 - ProgressCallback checks shutting_down_ + // This ensures we can abort during slow data transfer + SimpleLatch server_started(1); + + auto handler = [&](auto const&, auto send_response, auto send_sse_event, auto) { + http::response res{http::status::ok, 11}; + res.set(http::field::content_type, "text/event-stream"); + res.chunked(true); + send_response(res); + + server_started.count_down(); + + // Send one event then pause (simulating slow connection) + send_sse_event(SSEFormatter::event("first")); + std::this_thread::sleep_for(5000ms); // Pause to simulate slow connection + }; + + MockSSEServer server; + auto port = server.start(handler); + + IoContextRunner runner; + EventCollector collector; + + auto client = Builder(runner.context().get_executor(), "http://localhost:" + std::to_string(port)) + .receiver([&](Event e) { collector.add_event(std::move(e)); }) + .read_timeout(10000ms) // Long timeout so ProgressCallback is called but doesn't abort + .use_curl(true) + .build(); + + client->async_connect(); + + // Wait for first event and server pause + ASSERT_TRUE(server_started.wait_for(5000ms)); + ASSERT_TRUE(collector.wait_for_events(1, 5000ms)); + + // Shutdown while ProgressCallback is being invoked during the pause + auto shutdown_start = std::chrono::steady_clock::now(); + SimpleLatch shutdown_latch(1); + client->async_shutdown([&] { shutdown_latch.count_down(); }); + EXPECT_TRUE(shutdown_latch.wait_for(5000ms)); + auto shutdown_duration = std::chrono::steady_clock::now() - shutdown_start; + + // Shutdown should abort the transfer quickly + EXPECT_LT(shutdown_duration, 2000ms); +} + +TEST(CurlClientTest, MultipleShutdownCalls) { + // Ensures multiple shutdown calls don't cause issues (idempotency test) + MockSSEServer server; + auto port = server.start(TestHandlers::simple_event("test")); + + IoContextRunner runner; + EventCollector collector; + + auto client = Builder(runner.context().get_executor(), "http://localhost:" + std::to_string(port)) + .receiver([&](Event e) { collector.add_event(std::move(e)); }) + .use_curl(true) + .build(); + + client->async_connect(); + ASSERT_TRUE(collector.wait_for_events(1)); + + // Call shutdown multiple times in rapid succession + SimpleLatch shutdown_latch1(1); + SimpleLatch shutdown_latch2(1); + SimpleLatch shutdown_latch3(1); + + client->async_shutdown([&] { shutdown_latch1.count_down(); }); + client->async_shutdown([&] { shutdown_latch2.count_down(); }); + client->async_shutdown([&] { shutdown_latch3.count_down(); }); + + // All shutdown completions should be called + EXPECT_TRUE(shutdown_latch1.wait_for(5000ms)); + EXPECT_TRUE(shutdown_latch2.wait_for(5000ms)); + EXPECT_TRUE(shutdown_latch3.wait_for(5000ms)); +} + +TEST(CurlClientTest, ShutdownAfterConnectionClosed) { + // Tests shutdown when connection has already ended naturally + MockSSEServer server; + auto port = server.start([](auto const&, auto send_response, auto send_sse_event, auto close) { + http::response res{http::status::ok, 11}; + res.set(http::field::content_type, "text/event-stream"); + res.chunked(true); + send_response(res); + + send_sse_event(SSEFormatter::event("only event")); + std::this_thread::sleep_for(10ms); + close(); // Server closes connection + }); + + IoContextRunner runner; + EventCollector collector; + + auto client = Builder(runner.context().get_executor(), "http://localhost:" + std::to_string(port)) + .receiver([&](Event e) { collector.add_event(std::move(e)); }) + .initial_reconnect_delay(500ms) // Will try to reconnect after close + .use_curl(true) + .build(); + + client->async_connect(); + ASSERT_TRUE(collector.wait_for_events(1)); + + // Wait for connection to close and reconnect attempt to start + std::this_thread::sleep_for(200ms); + + // Shutdown after natural connection close + SimpleLatch shutdown_latch(1); + client->async_shutdown([&] { shutdown_latch.count_down(); }); + EXPECT_TRUE(shutdown_latch.wait_for(5000ms)); +} + +TEST(CurlClientTest, ShutdownDuringConnectionAttempt) { + // Tests curl_client.cpp:439 - perform_request checks shutting_down_ at start + // Server that delays before responding to test shutdown during connection phase + SimpleLatch connection_started(1); + + auto handler = [&](auto const&, auto send_response, auto send_sse_event, auto close) { + connection_started.count_down(); + // Delay before responding + std::this_thread::sleep_for(500ms); + + http::response res{http::status::ok, 11}; + res.set(http::field::content_type, "text/event-stream"); + res.chunked(true); + send_response(res); + + send_sse_event(SSEFormatter::event("test")); + std::this_thread::sleep_for(10ms); + close(); + }; + + MockSSEServer server; + auto port = server.start(handler); + + IoContextRunner runner; + EventCollector collector; + + auto client = Builder(runner.context().get_executor(), "http://localhost:" + std::to_string(port)) + .receiver([&](Event e) { collector.add_event(std::move(e)); }) + .use_curl(true) + .build(); + + client->async_connect(); + + // Wait for connection to start but shutdown before it completes + ASSERT_TRUE(connection_started.wait_for(5000ms)); + std::this_thread::sleep_for(50ms); // Give CURL time to start but not finish + + auto shutdown_start = std::chrono::steady_clock::now(); + SimpleLatch shutdown_latch(1); + client->async_shutdown([&] { shutdown_latch.count_down(); }); + EXPECT_TRUE(shutdown_latch.wait_for(5000ms)); + auto shutdown_duration = std::chrono::steady_clock::now() - shutdown_start; + + // Shutdown should abort the pending connection quickly + EXPECT_LT(shutdown_duration, 2000ms); + + // Should not have received any events since we shutdown during connection + EXPECT_EQ(0, collector.events().size()); +} diff --git a/libs/server-sent-events/tests/mock_sse_server.hpp b/libs/server-sent-events/tests/mock_sse_server.hpp index d07da5566..760520565 100644 --- a/libs/server-sent-events/tests/mock_sse_server.hpp +++ b/libs/server-sent-events/tests/mock_sse_server.hpp @@ -6,11 +6,12 @@ #include #include #include -#include #include #include #include #include +#include +#include namespace launchdarkly::sse::test { @@ -29,7 +30,7 @@ class MockSSEServer { using RequestHandler = std::function const& req, std::function)> send_response, - std::function send_sse_event, + std::function send_sse_event, std::function close_connection )>; @@ -49,7 +50,6 @@ class MockSSEServer { * Returns the port number. */ uint16_t start(RequestHandler handler, bool use_ssl = false) { - std::cout << "[MockServer] start: initializing server" << std::endl; handler_ = std::move(handler); use_ssl_ = use_ssl; @@ -63,18 +63,12 @@ class MockSSEServer { port_ = acceptor_.local_endpoint().port(); running_ = true; - std::cout << "[MockServer] Server bound to port " << port_ << std::endl; - // Start accepting connections in a background thread server_thread_ = std::thread([this]() { - std::cout << "[MockServer] Background thread started" << std::endl; do_accept(); - std::cout << "[MockServer] About to run io_context" << std::endl; ioc_.run(); - std::cout << "[MockServer] io_context.run() exited" << std::endl; }); - std::cout << "[MockServer] Server started on port " << port_ << std::endl; return port_; } @@ -85,6 +79,17 @@ class MockSSEServer { running_ = false; + // Close all active connections + { + std::lock_guard lock(connections_mutex_); + for (auto& conn : active_connections_) { + if (auto c = conn.lock()) { + c->force_close(); + } + } + active_connections_.clear(); + } + boost::system::error_code ec; acceptor_.close(ec); @@ -105,19 +110,12 @@ class MockSSEServer { private: void do_accept() { if (!running_) { - std::cout << "[MockServer] do_accept: not running, returning" << std::endl; return; } - std::cout << "[MockServer] do_accept: waiting for connection on port " << port_ << std::endl; acceptor_.async_accept( [this](boost::system::error_code ec, tcp::socket socket) { - if (ec) { - std::cout << "[MockServer] async_accept error: " << ec.message() << std::endl; - } else { - std::cout << "[MockServer] Connection accepted from " - << socket.remote_endpoint().address().to_string() - << ":" << socket.remote_endpoint().port() << std::endl; + if (!ec) { handle_connection(std::move(socket)); } @@ -128,11 +126,16 @@ class MockSSEServer { } void handle_connection(tcp::socket socket) { - std::cout << "[MockServer] handle_connection: creating Connection object" << std::endl; auto conn = std::make_shared( std::move(socket), handler_); + + // Track active connections + { + std::lock_guard lock(connections_mutex_); + active_connections_.push_back(conn); + } + conn->start(); - std::cout << "[MockServer] handle_connection: Connection started" << std::endl; } struct Connection : std::enable_shared_from_this { @@ -149,38 +152,34 @@ class MockSSEServer { } void start() { - std::cout << "[Connection] start: beginning read" << std::endl; do_read(); } + void force_close() { + closed_ = true; + boost::system::error_code ec; + socket_.shutdown(tcp::socket::shutdown_both, ec); + socket_.close(ec); + } + void do_read() { - std::cout << "[Connection] do_read: async_read started" << std::endl; auto self = shared_from_this(); http::async_read( socket_, buffer_, req_, - [self](boost::system::error_code ec, std::size_t bytes) { - if (ec) { - std::cout << "[Connection] async_read error: " << ec.message() << std::endl; - return; + [self](boost::system::error_code ec, std::size_t) { + if (!ec) { + self->handle_request(); } - std::cout << "[Connection] async_read success: " << bytes << " bytes read" << std::endl; - std::cout << "[Connection] Request: " << self->req_.method_string() << " " - << self->req_.target() << std::endl; - self->handle_request(); }); } void handle_request() { - std::cout << "[Connection] handle_request: setting up callbacks" << std::endl; auto self = shared_from_this(); auto send_response = [self](http::response res) { - std::cout << "[Connection] send_response called: status=" << res.result_int() - << ", chunked=" << res.chunked() << std::endl; if (self->closed_) { - std::cout << "[Connection] send_response: already closed, skipping" << std::endl; return; } @@ -188,18 +187,11 @@ class MockSSEServer { // For error responses and redirects (with body or no SSE), write complete response if (res.result() != http::status::ok || !res.chunked()) { - std::cout << "[Connection] Sending complete response" << std::endl; http::write(self->socket_, res, ec); - if (ec) { - std::cout << "[Connection] Error writing response: " << ec.message() << std::endl; - } else { - std::cout << "[Connection] Complete response sent successfully" << std::endl; - } return; } // For SSE (chunked OK responses), manually send headers to keep connection open - std::cout << "[Connection] Sending SSE response headers" << std::endl; std::ostringstream oss; oss << "HTTP/1.1 " << res.result_int() << " " << res.reason() << "\r\n"; for (auto const& field : res) { @@ -208,20 +200,12 @@ class MockSSEServer { oss << "\r\n"; // End of headers std::string header_str = oss.str(); - std::cout << "[Connection] Headers to send:\n" << header_str << std::endl; net::write(self->socket_, net::buffer(header_str), ec); - if (ec) { - std::cout << "[Connection] Error writing headers: " << ec.message() << std::endl; - } else { - std::cout << "[Connection] Headers sent successfully, " << header_str.size() << " bytes" << std::endl; - } }; - auto send_sse_event = [self](std::string const& data) { - std::cout << "[Connection] send_sse_event called: " << data.size() << " bytes" << std::endl; + auto send_sse_event = [self](std::string const& data) -> bool { if (self->closed_) { - std::cout << "[Connection] send_sse_event: already closed, skipping" << std::endl; - return; + return false; } boost::system::error_code ec; @@ -231,21 +215,19 @@ class MockSSEServer { chunk << std::hex << data.size() << "\r\n" << data << "\r\n"; std::string chunk_str = chunk.str(); - std::cout << "[Connection] Sending chunk: size=" << data.size() - << ", total chunk size=" << chunk_str.size() << " bytes" << std::endl; - net::write(self->socket_, net::buffer(chunk_str), ec); + + // If write failed, mark connection as closed to prevent further writes if (ec) { - std::cout << "[Connection] Error writing SSE data: " << ec.message() << std::endl; - } else { - std::cout << "[Connection] SSE chunk sent successfully" << std::endl; + self->closed_ = true; + return false; } + + return true; }; auto close_connection = [self]() { - std::cout << "[Connection] close_connection called" << std::endl; if (self->closed_) { - std::cout << "[Connection] Already closed" << std::endl; return; } self->closed_ = true; @@ -253,29 +235,13 @@ class MockSSEServer { // Send final chunk terminator for chunked encoding boost::system::error_code ec; std::string final_chunk = "0\r\n\r\n"; - std::cout << "[Connection] Sending final chunk terminator" << std::endl; net::write(self->socket_, net::buffer(final_chunk), ec); - if (ec) { - std::cout << "[Connection] Error writing final chunk: " << ec.message() << std::endl; - } else { - std::cout << "[Connection] Final chunk sent" << std::endl; - } self->socket_.shutdown(tcp::socket::shutdown_both, ec); - if (ec) { - std::cout << "[Connection] Error during shutdown: " << ec.message() << std::endl; - } self->socket_.close(ec); - if (ec) { - std::cout << "[Connection] Error during close: " << ec.message() << std::endl; - } else { - std::cout << "[Connection] Connection closed successfully" << std::endl; - } }; - std::cout << "[Connection] Calling handler" << std::endl; handler_(req_, send_response, send_sse_event, close_connection); - std::cout << "[Connection] Handler returned" << std::endl; } }; @@ -286,6 +252,8 @@ class MockSSEServer { std::atomic running_; std::atomic port_; bool use_ssl_; + std::mutex connections_mutex_; + std::vector> active_connections_; }; /** @@ -349,13 +317,11 @@ class TestHandlers { res.keep_alive(true); res.chunked(true); - // Send response headers send_response(res); + if (!send_sse_event(SSEFormatter::event(data))) { + return; // Connection closed or error + } - // Send SSE event - send_sse_event(SSEFormatter::event(data)); - - // Close connection after a brief delay std::this_thread::sleep_for(std::chrono::milliseconds(10)); close(); }; @@ -376,7 +342,9 @@ class TestHandlers { send_response(res); for (auto const& data : events) { - send_sse_event(SSEFormatter::event(data)); + if (!send_sse_event(SSEFormatter::event(data))) { + return; // Connection closed or error + } std::this_thread::sleep_for(std::chrono::milliseconds(5)); } @@ -453,7 +421,9 @@ class TestHandlers { info += "body: " + req.body() + "\n"; } - send_sse_event(SSEFormatter::event(info)); + if (!send_sse_event(SSEFormatter::event(info))) { + return; // Connection closed or error + } std::this_thread::sleep_for(std::chrono::milliseconds(10)); close(); From b860b2bd2b051b95cc11e8f608e1d67e17da80ac Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 13 Oct 2025 14:07:18 -0700 Subject: [PATCH 30/90] Conditional CURL compilation. --- CMakeLists.txt | 2 + README.md | 1 + .../include/entity_manager.hpp | 5 +- .../sse-contract-tests/include/server.hpp | 4 +- .../sse-contract-tests/src/entity_manager.cpp | 10 +-- .../sse-contract-tests/src/main.cpp | 8 +-- .../sse-contract-tests/src/server.cpp | 5 +- .../src/data_sources/polling_data_source.cpp | 2 + .../src/data_sources/polling_data_source.hpp | 1 + .../data_sources/streaming_data_source.cpp | 2 - .../events/detail/request_worker.hpp | 1 + .../launchdarkly/network/curl_requester.hpp | 4 ++ .../launchdarkly/network/requester.hpp | 39 +++++++---- libs/internal/src/CMakeLists.txt | 21 ++++-- libs/internal/src/events/request_worker.cpp | 3 + libs/internal/src/network/curl_requester.cpp | 6 +- libs/internal/src/network/requester.cpp | 66 +++++++++++++++++++ libs/internal/tests/CMakeLists.txt | 4 ++ libs/internal/tests/curl_requester_test.cpp | 4 ++ .../include/launchdarkly/sse/client.hpp | 10 --- libs/server-sent-events/src/CMakeLists.txt | 18 +++-- libs/server-sent-events/src/client.cpp | 16 ++--- libs/server-sent-events/src/curl_client.cpp | 4 ++ libs/server-sent-events/src/curl_client.hpp | 4 ++ libs/server-sent-events/tests/CMakeLists.txt | 4 ++ .../tests/curl_client_test.cpp | 34 ++-------- 26 files changed, 182 insertions(+), 96 deletions(-) create mode 100644 libs/internal/src/network/requester.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 7060443a0..d06598c8d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -108,6 +108,8 @@ option(LD_BUILD_EXAMPLES "Build hello-world examples." ON) option(LD_BUILD_REDIS_SUPPORT "Build redis support." OFF) +option(LD_CURL_NETWORKING "Enable CURL-based networking for SSE client (alternative to Boost.Beast/Foxy)" OFF) + # If using 'make' as the build system, CMake causes the 'install' target to have a dependency on 'all', meaning # it will cause a full build. This disables that, allowing us to build piecemeal instead. This is useful # so that we only need to build the client or server for a given release (if only the client or server were affected.) diff --git a/README.md b/README.md index 99db02fc2..668f05e14 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,7 @@ Various CMake options are available to customize the client/server SDK builds. | `LD_DYNAMIC_LINK_BOOST` | If building SDK as shared lib, whether to dynamically link Boost or not. Ensure that the shared boost libraries are present on the target system. | On (link boost dynamically when producing shared libs) | `LD_BUILD_SHARED_LIBS` | | `LD_DYNAMIC_LINK_OPENSSL` | Whether OpenSSL is dynamically linked or not. | Off (static link) | N/A | | `LD_BUILD_REDIS_SUPPORT` | Whether the server-side Redis Source is built or not. | Off | N/A | +| `LD_CURL_NETWORKING` | Enable CURL-based networking for all HTTP requests (SSE streams and event delivery). When OFF, Boost.Beast/Foxy is used instead. CURL must be available as a dependency when this option is OFF. | On | N/A | > [!WARNING] > When building shared libraries C++ symbols are not exported, only the C API will be exported. This is because C++ does diff --git a/contract-tests/sse-contract-tests/include/entity_manager.hpp b/contract-tests/sse-contract-tests/include/entity_manager.hpp index f6f71ee38..93a35f0b6 100644 --- a/contract-tests/sse-contract-tests/include/entity_manager.hpp +++ b/contract-tests/sse-contract-tests/include/entity_manager.hpp @@ -26,7 +26,6 @@ class EntityManager { boost::asio::any_io_executor executor_; launchdarkly::Logger& logger_; - bool use_curl_; public: /** @@ -34,11 +33,9 @@ class EntityManager { * entities (SSE clients + event channel back to test harness). * @param executor Executor. * @param logger Logger. - * @param use_curl Whether to use CURL implementation for SSE clients. */ EntityManager(boost::asio::any_io_executor executor, - launchdarkly::Logger& logger, - bool use_curl = false); + launchdarkly::Logger& logger); /** * Create an entity with the given configuration. * @param params Config of the entity. diff --git a/contract-tests/sse-contract-tests/include/server.hpp b/contract-tests/sse-contract-tests/include/server.hpp index 2d8158703..778b6b500 100644 --- a/contract-tests/sse-contract-tests/include/server.hpp +++ b/contract-tests/sse-contract-tests/include/server.hpp @@ -30,13 +30,11 @@ class server { * @param address Address to bind. * @param port Port to bind. * @param logger Logger. - * @param use_curl Whether to use CURL implementation for SSE clients. */ server(net::io_context& ioc, std::string const& address, unsigned short port, - launchdarkly::Logger& logger, - bool use_curl = false); + launchdarkly::Logger& logger); /** * Advertise an optional test-harness capability, such as "comments". * @param cap diff --git a/contract-tests/sse-contract-tests/src/entity_manager.cpp b/contract-tests/sse-contract-tests/src/entity_manager.cpp index 0e2c2af6f..798d55ce5 100644 --- a/contract-tests/sse-contract-tests/src/entity_manager.cpp +++ b/contract-tests/sse-contract-tests/src/entity_manager.cpp @@ -4,9 +4,8 @@ using launchdarkly::LogLevel; EntityManager::EntityManager(boost::asio::any_io_executor executor, - launchdarkly::Logger& logger, - bool use_curl) - : counter_{0}, executor_{std::move(executor)}, logger_{logger}, use_curl_{use_curl} {} + launchdarkly::Logger& logger) + : counter_{0}, executor_{std::move(executor)}, logger_{logger} {} std::optional EntityManager::create(ConfigParams const& params) { std::string id = std::to_string(counter_++); @@ -41,11 +40,6 @@ std::optional EntityManager::create(ConfigParams const& params) { std::chrono::milliseconds(*params.initialDelayMs)); } - // Use the global CURL setting from command line - if (use_curl_) { - client_builder.use_curl(true); - } - client_builder.logger([this](std::string msg) { LD_LOG(logger_, LogLevel::kDebug) << std::move(msg); }); diff --git a/contract-tests/sse-contract-tests/src/main.cpp b/contract-tests/sse-contract-tests/src/main.cpp index d9e0d6528..a37bc0d7f 100644 --- a/contract-tests/sse-contract-tests/src/main.cpp +++ b/contract-tests/sse-contract-tests/src/main.cpp @@ -22,15 +22,11 @@ int main(int argc, char* argv[]) { std::string const default_port = "8123"; std::string port = default_port; - bool use_curl = false; // Parse command line arguments for (int i = 1; i < argc; ++i) { std::string arg = argv[i]; // NOLINT(cppcoreguidelines-pro-bounds-pointer-arithmetic) - if (arg == "--use-curl") { - use_curl = true; - LD_LOG(logger, LogLevel::kInfo) << "Using CURL implementation for SSE clients"; - } else if (i == 1 && arg.find("--") != 0) { + if (i == 1 && arg.find("--") != 0) { // First non-flag argument is the port port = arg; } @@ -40,7 +36,7 @@ int main(int argc, char* argv[]) { net::io_context ioc{1}; server srv(ioc, "0.0.0.0", boost::lexical_cast(port), - logger, use_curl); + logger); srv.add_capability("headers"); srv.add_capability("comments"); diff --git a/contract-tests/sse-contract-tests/src/server.cpp b/contract-tests/sse-contract-tests/src/server.cpp index a95685904..5d009628d 100644 --- a/contract-tests/sse-contract-tests/src/server.cpp +++ b/contract-tests/sse-contract-tests/src/server.cpp @@ -13,11 +13,10 @@ using launchdarkly::LogLevel; server::server(net::io_context& ioc, std::string const& address, unsigned short port, - launchdarkly::Logger& logger, - bool use_curl) + launchdarkly::Logger& logger) : listener_{ioc.get_executor(), tcp::endpoint(boost::asio::ip::make_address(address), port)}, - entity_manager_{ioc.get_executor(), logger, use_curl}, + entity_manager_{ioc.get_executor(), logger}, logger_{logger} { LD_LOG(logger_, LogLevel::kInfo) << "server: listening on " << address << ":" << port; diff --git a/libs/client-sdk/src/data_sources/polling_data_source.cpp b/libs/client-sdk/src/data_sources/polling_data_source.cpp index 762e7dce0..4e50f612b 100644 --- a/libs/client-sdk/src/data_sources/polling_data_source.cpp +++ b/libs/client-sdk/src/data_sources/polling_data_source.cpp @@ -1,4 +1,6 @@ #include +#include +#include #include #include diff --git a/libs/client-sdk/src/data_sources/polling_data_source.hpp b/libs/client-sdk/src/data_sources/polling_data_source.hpp index 160a22ab2..50a81760c 100644 --- a/libs/client-sdk/src/data_sources/polling_data_source.hpp +++ b/libs/client-sdk/src/data_sources/polling_data_source.hpp @@ -14,6 +14,7 @@ #include #include +#include namespace launchdarkly::client_side::data_sources { diff --git a/libs/client-sdk/src/data_sources/streaming_data_source.cpp b/libs/client-sdk/src/data_sources/streaming_data_source.cpp index 4a5c928a7..d28b36677 100644 --- a/libs/client-sdk/src/data_sources/streaming_data_source.cpp +++ b/libs/client-sdk/src/data_sources/streaming_data_source.cpp @@ -88,8 +88,6 @@ void StreamingDataSource::Start() { auto client_builder = launchdarkly::sse::Builder(exec_, url.buffer()); - client_builder.use_curl(true); - client_builder.method(data_source_config_.use_report ? boost::beast::http::verb::report : boost::beast::http::verb::get); diff --git a/libs/internal/include/launchdarkly/events/detail/request_worker.hpp b/libs/internal/include/launchdarkly/events/detail/request_worker.hpp index 02c86204e..7d1dd5b40 100644 --- a/libs/internal/include/launchdarkly/events/detail/request_worker.hpp +++ b/libs/internal/include/launchdarkly/events/detail/request_worker.hpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include diff --git a/libs/internal/include/launchdarkly/network/curl_requester.hpp b/libs/internal/include/launchdarkly/network/curl_requester.hpp index c1ae50f7b..49bf73e39 100644 --- a/libs/internal/include/launchdarkly/network/curl_requester.hpp +++ b/libs/internal/include/launchdarkly/network/curl_requester.hpp @@ -1,5 +1,7 @@ #pragma once +#ifdef LD_CURL_NETWORKING + #include "http_requester.hpp" #include "asio_requester.hpp" #include @@ -26,3 +28,5 @@ class CurlRequester { }; } // namespace launchdarkly::network + +#endif // LD_CURL_NETWORKING diff --git a/libs/internal/include/launchdarkly/network/requester.hpp b/libs/internal/include/launchdarkly/network/requester.hpp index bfb39a14a..58579cc06 100644 --- a/libs/internal/include/launchdarkly/network/requester.hpp +++ b/libs/internal/include/launchdarkly/network/requester.hpp @@ -1,27 +1,44 @@ #pragma once #include "http_requester.hpp" -// #include "asio_requester.hpp" -#include "curl_requester.hpp" #include #include - +#include +#include namespace launchdarkly::network { +namespace net = boost::asio; using TlsOptions = config::shared::built::TlsOptions; -typedef std::function CallbackFunction; +// Forward declaration to hide implementation details +class IRequesterImpl; + +/** + * Requester provides HTTP request functionality using either CURL or Boost.Beast + * depending on the LD_CURL_NETWORKING compile-time flag. + * + * When LD_CURL_NETWORKING is ON: Uses CurlRequester (CURL-based implementation) + * When LD_CURL_NETWORKING is OFF: Uses AsioRequester (Boost.Beast-based implementation) + * + * The implementation choice is made at library compile-time and hidden from users + * via the pimpl idiom to avoid ABI issues. + */ class Requester { - CurlRequester innerRequester_; public: - Requester(net::any_io_executor ctx, TlsOptions const& tls_options): innerRequester_(ctx, tls_options) {} + Requester(net::any_io_executor ctx, TlsOptions const& tls_options); + ~Requester(); + + // Move-only type + Requester(Requester&&) noexcept; + Requester& operator=(Requester&&) noexcept; + Requester(const Requester&) = delete; + Requester& operator=(const Requester&) = delete; + + void Request(HttpRequest request, std::function cb); - void Request(HttpRequest request, std::function cb) { - innerRequester_.Request(request, [cb](const HttpResult &res) { - cb(res); - }); - } +private: + std::unique_ptr impl_; }; } // namespace launchdarkly::network diff --git a/libs/internal/src/CMakeLists.txt b/libs/internal/src/CMakeLists.txt index a0914a81b..88d6209dc 100644 --- a/libs/internal/src/CMakeLists.txt +++ b/libs/internal/src/CMakeLists.txt @@ -10,7 +10,7 @@ file(GLOB HEADER_LIST CONFIGURE_DEPENDS ) # Automatic library: static or dynamic based on user config. -add_library(${LIBNAME} OBJECT +set(INTERNAL_SOURCES ${HEADER_LIST} context_filter.cpp events/asio_event_processor.cpp @@ -27,7 +27,7 @@ add_library(${LIBNAME} OBJECT logging/logger.cpp network/http_error_messages.cpp network/http_requester.cpp - network/curl_requester.cpp + network/requester.cpp serialization/events/json_events.cpp serialization/json_attributes.cpp serialization/json_context.cpp @@ -47,6 +47,14 @@ add_library(${LIBNAME} OBJECT encoding/sha_1.cpp signals/boost_signal_connection.cpp) +if (LD_CURL_NETWORKING) + message(STATUS "LaunchDarkly Internal: CURL networking enabled") + find_package(CURL REQUIRED) + list(APPEND INTERNAL_SOURCES network/curl_requester.cpp) +endif() + +add_library(${LIBNAME} OBJECT ${INTERNAL_SOURCES}) + add_library(launchdarkly::internal ALIAS ${LIBNAME}) # TODO(SC-209963): Remove once OpenSSL deprecated hash function usage has been updated @@ -57,11 +65,14 @@ target_compile_options(${LIBNAME} PRIVATE message(STATUS "LaunchDarklyInternalSdk_SOURCE_DIR=${LaunchDarklyInternalSdk_SOURCE_DIR}") -find_package(CURL) - target_link_libraries(${LIBNAME} PUBLIC launchdarkly::common - PRIVATE Boost::url Boost::json OpenSSL::SSL Boost::disable_autolinking Boost::headers tl::expected foxy CURL::libcurl) + PRIVATE Boost::url Boost::json OpenSSL::SSL Boost::disable_autolinking Boost::headers tl::expected foxy) + +if (LD_CURL_NETWORKING) + target_link_libraries(${LIBNAME} PRIVATE CURL::libcurl) + target_compile_definitions(${LIBNAME} PRIVATE LD_CURL_NETWORKING) +endif() # Need the public headers to build. target_include_directories(${LIBNAME} diff --git a/libs/internal/src/events/request_worker.cpp b/libs/internal/src/events/request_worker.cpp index 4548985ee..0e160430e 100644 --- a/libs/internal/src/events/request_worker.cpp +++ b/libs/internal/src/events/request_worker.cpp @@ -1,8 +1,11 @@ #include #include +#include namespace launchdarkly::events::detail { +namespace http = boost::beast::http; + RequestWorker::RequestWorker(boost::asio::any_io_executor io, std::chrono::milliseconds retry_after, std::size_t id, diff --git a/libs/internal/src/network/curl_requester.cpp b/libs/internal/src/network/curl_requester.cpp index fd2246850..7eb1c0cb3 100644 --- a/libs/internal/src/network/curl_requester.cpp +++ b/libs/internal/src/network/curl_requester.cpp @@ -1,3 +1,5 @@ +#ifdef LD_CURL_NETWORKING + #include "launchdarkly/network/curl_requester.hpp" #include #include @@ -224,4 +226,6 @@ void CurlRequester::PerformRequestStatic(net::any_io_executor ctx, TlsOptions co }); } -} \ No newline at end of file +} // namespace launchdarkly::network + +#endif // LD_CURL_NETWORKING diff --git a/libs/internal/src/network/requester.cpp b/libs/internal/src/network/requester.cpp new file mode 100644 index 000000000..d9e7bdee8 --- /dev/null +++ b/libs/internal/src/network/requester.cpp @@ -0,0 +1,66 @@ +#include + +#ifdef LD_CURL_NETWORKING +#include +#else +#include +#endif + +namespace launchdarkly::network { + +// Abstract interface for the implementation +class IRequesterImpl { +public: + virtual ~IRequesterImpl() = default; + virtual void Request(HttpRequest request, std::function cb) = 0; +}; + +#ifdef LD_CURL_NETWORKING +// CURL-based implementation +class CurlRequesterImpl : public IRequesterImpl { +public: + CurlRequesterImpl(net::any_io_executor ctx, TlsOptions const& tls_options) + : requester_(ctx, tls_options) {} + + void Request(HttpRequest request, std::function cb) override { + requester_.Request(std::move(request), std::move(cb)); + } + +private: + CurlRequester requester_; +}; +#else +// Boost.Beast-based implementation +class AsioRequesterImpl : public IRequesterImpl { +public: + AsioRequesterImpl(net::any_io_executor ctx, TlsOptions const& tls_options) + : requester_(ctx, tls_options) {} + + void Request(HttpRequest request, std::function cb) override { + requester_.Request(std::move(request), std::move(cb)); + } + +private: + AsioRequester requester_; +}; +#endif + +// Requester implementation +Requester::Requester(net::any_io_executor ctx, TlsOptions const& tls_options) { +#ifdef LD_CURL_NETWORKING + impl_ = std::make_unique(ctx, tls_options); +#else + impl_ = std::make_unique(ctx, tls_options); +#endif +} + +Requester::~Requester() = default; + +Requester::Requester(Requester&&) noexcept = default; +Requester& Requester::operator=(Requester&&) noexcept = default; + +void Requester::Request(HttpRequest request, std::function cb) { + impl_->Request(std::move(request), std::move(cb)); +} + +} // namespace launchdarkly::network diff --git a/libs/internal/tests/CMakeLists.txt b/libs/internal/tests/CMakeLists.txt index 69c9ffe3c..94d9141e2 100644 --- a/libs/internal/tests/CMakeLists.txt +++ b/libs/internal/tests/CMakeLists.txt @@ -18,4 +18,8 @@ add_executable(gtest_${LIBNAME} ${tests}) target_link_libraries(gtest_${LIBNAME} launchdarkly::common launchdarkly::internal foxy GTest::gtest_main) +if (LD_CURL_NETWORKING) + target_compile_definitions(gtest_${LIBNAME} PRIVATE LD_CURL_NETWORKING) +endif() + gtest_discover_tests(gtest_${LIBNAME}) diff --git a/libs/internal/tests/curl_requester_test.cpp b/libs/internal/tests/curl_requester_test.cpp index 4764223d5..223c40afe 100644 --- a/libs/internal/tests/curl_requester_test.cpp +++ b/libs/internal/tests/curl_requester_test.cpp @@ -1,3 +1,5 @@ +#ifdef LD_CURL_NETWORKING + #include #include @@ -326,3 +328,5 @@ TEST_F(CurlRequesterTest, HandlesInvalidUrl) { ASSERT_TRUE(callback_called); EXPECT_TRUE(result.IsError()); } + +#endif // LD_CURL_NETWORKING diff --git a/libs/server-sent-events/include/launchdarkly/sse/client.hpp b/libs/server-sent-events/include/launchdarkly/sse/client.hpp index 9108e3390..2bf93989c 100644 --- a/libs/server-sent-events/include/launchdarkly/sse/client.hpp +++ b/libs/server-sent-events/include/launchdarkly/sse/client.hpp @@ -153,15 +153,6 @@ class Builder { */ Builder& custom_ca_file(std::string path); - /** - * Use the CURL-based implementation instead of the default Boost.Beast/Foxy - * implementation. - * - * @param use_curl True to use CURL implementation, false for Foxy. - * @return Reference to this builder. - */ - Builder& use_curl(bool use_curl); - /** * Builds a Client. The shared pointer is necessary to extend the lifetime * of the Client to encompass each asynchronous operation that it performs. @@ -183,7 +174,6 @@ class Builder { ErrorCallback error_cb_; bool skip_verify_peer_; std::optional custom_ca_file_; - bool use_curl_; }; /** diff --git a/libs/server-sent-events/src/CMakeLists.txt b/libs/server-sent-events/src/CMakeLists.txt index 597f7f06c..7c6e7d1d5 100644 --- a/libs/server-sent-events/src/CMakeLists.txt +++ b/libs/server-sent-events/src/CMakeLists.txt @@ -5,23 +5,33 @@ file(GLOB HEADER_LIST CONFIGURE_DEPENDS ) # Automatic library: static or dynamic based on user config. -add_library(${LIBNAME} OBJECT +set(SSE_SOURCES ${HEADER_LIST} client.cpp - curl_client.cpp parser.cpp event.cpp error.cpp backoff_detail.cpp backoff.cpp) -find_package(CURL) +if (LD_CURL_NETWORKING) + message(STATUS "LaunchDarkly SSE: CURL networking enabled") + find_package(CURL REQUIRED) + list(APPEND SSE_SOURCES curl_client.cpp) +endif() + +add_library(${LIBNAME} OBJECT ${SSE_SOURCES}) target_link_libraries(${LIBNAME} PUBLIC OpenSSL::SSL Boost::headers foxy - PRIVATE Boost::url Boost::disable_autolinking CURL::libcurl + PRIVATE Boost::url Boost::disable_autolinking ) +if (LD_CURL_NETWORKING) + target_link_libraries(${LIBNAME} PRIVATE CURL::libcurl) + target_compile_definitions(${LIBNAME} PRIVATE LD_CURL_NETWORKING) +endif() + add_library(launchdarkly::sse ALIAS ${LIBNAME}) # Need the public headers to build. diff --git a/libs/server-sent-events/src/client.cpp b/libs/server-sent-events/src/client.cpp index 8d057c1c2..428439377 100644 --- a/libs/server-sent-events/src/client.cpp +++ b/libs/server-sent-events/src/client.cpp @@ -8,7 +8,9 @@ #include "backoff.hpp" #include "parser.hpp" +#ifdef LD_CURL_NETWORKING #include "curl_client.hpp" +#endif #include #include @@ -514,8 +516,7 @@ Builder::Builder(net::any_io_executor ctx, std::string url) receiver_([](launchdarkly::sse::Event const&) {}), error_cb_([](auto err) {}), skip_verify_peer_(false), - custom_ca_file_(std::nullopt), - use_curl_(false) { + custom_ca_file_(std::nullopt) { request_.version(11); request_.set(http::field::user_agent, kDefaultUserAgent); request_.method(http::verb::get); @@ -587,11 +588,6 @@ Builder& Builder::custom_ca_file(std::string path) { return *this; } -Builder& Builder::use_curl(bool use_curl) { - use_curl_ = use_curl; - return *this; -} - std::shared_ptr Builder::build() { auto uri_components = boost::urls::parse_uri(url_); if (!uri_components) { @@ -634,15 +630,14 @@ std::shared_ptr Builder::build() { std::string service = uri_components->has_port() ? uri_components->port() : uri_components->scheme(); - if (use_curl_) { +#ifdef LD_CURL_NETWORKING bool use_https = uri_components->scheme_id() == boost::urls::scheme::https; return std::make_shared( net::make_strand(executor_), request, host, service, connect_timeout_, read_timeout_, write_timeout_, initial_reconnect_delay_, receiver_, logging_cb_, error_cb_, skip_verify_peer_, custom_ca_file_, use_https); - } - +#else std::optional ssl; if (uri_components->scheme_id() == boost::urls::scheme::https) { ssl = launchdarkly::foxy::make_ssl_ctx(ssl::context::tlsv12_client); @@ -664,6 +659,7 @@ std::shared_ptr Builder::build() { net::make_strand(executor_), request, host, service, connect_timeout_, read_timeout_, write_timeout_, initial_reconnect_delay_, receiver_, logging_cb_, error_cb_, std::move(ssl)); +#endif } } // namespace launchdarkly::sse diff --git a/libs/server-sent-events/src/curl_client.cpp b/libs/server-sent-events/src/curl_client.cpp index 46914d980..1f363c657 100644 --- a/libs/server-sent-events/src/curl_client.cpp +++ b/libs/server-sent-events/src/curl_client.cpp @@ -1,3 +1,5 @@ +#ifdef LD_CURL_NETWORKING + #include "curl_client.hpp" #include @@ -692,3 +694,5 @@ void CurlClient::report_error(Error error) { } } // namespace launchdarkly::sse + +#endif // LD_CURL_NETWORKING diff --git a/libs/server-sent-events/src/curl_client.hpp b/libs/server-sent-events/src/curl_client.hpp index 1cfc3a248..eded302e5 100644 --- a/libs/server-sent-events/src/curl_client.hpp +++ b/libs/server-sent-events/src/curl_client.hpp @@ -1,5 +1,7 @@ #pragma once +#ifdef LD_CURL_NETWORKING + #include #include "backoff.hpp" #include "parser.hpp" @@ -109,3 +111,5 @@ class CurlClient : public Client, }; } // namespace launchdarkly::sse + +#endif // LD_CURL_NETWORKING diff --git a/libs/server-sent-events/tests/CMakeLists.txt b/libs/server-sent-events/tests/CMakeLists.txt index 270231a9d..5a1b5d813 100644 --- a/libs/server-sent-events/tests/CMakeLists.txt +++ b/libs/server-sent-events/tests/CMakeLists.txt @@ -18,4 +18,8 @@ add_executable(gtest_${LIBNAME} ${tests}) target_link_libraries(gtest_${LIBNAME} launchdarkly::sse foxy GTest::gtest_main GTest::gmock) +if (LD_CURL_NETWORKING) + target_compile_definitions(gtest_${LIBNAME} PRIVATE LD_CURL_NETWORKING) +endif() + gtest_discover_tests(gtest_${LIBNAME}) diff --git a/libs/server-sent-events/tests/curl_client_test.cpp b/libs/server-sent-events/tests/curl_client_test.cpp index 4a4d8b6db..e2a6dcd0c 100644 --- a/libs/server-sent-events/tests/curl_client_test.cpp +++ b/libs/server-sent-events/tests/curl_client_test.cpp @@ -1,3 +1,5 @@ +#ifdef LD_CURL_NETWORKING + #include #include @@ -132,7 +134,6 @@ TEST(CurlClientTest, ConnectsToHttpServer) { auto client = Builder(runner.context().get_executor(), "http://localhost:" + std::to_string(port)) .receiver([&](Event e) { collector.add_event(std::move(e)); }) - .use_curl(true) .build(); client->async_connect(); @@ -156,7 +157,6 @@ TEST(CurlClientTest, HandlesMultipleEvents) { auto client = Builder(runner.context().get_executor(), "http://localhost:" + std::to_string(port)) .receiver([&](Event e) { collector.add_event(std::move(e)); }) - .use_curl(true) .build(); client->async_connect(); @@ -193,7 +193,6 @@ TEST(CurlClientTest, ParsesEventWithType) { auto client = Builder(runner.context().get_executor(), "http://localhost:" + std::to_string(port)) .receiver([&](Event e) { collector.add_event(std::move(e)); }) - .use_curl(true) .build(); client->async_connect(); @@ -227,7 +226,6 @@ TEST(CurlClientTest, ParsesEventWithId) { auto client = Builder(runner.context().get_executor(), "http://localhost:" + std::to_string(port)) .receiver([&](Event e) { collector.add_event(std::move(e)); }) - .use_curl(true) .build(); client->async_connect(); @@ -261,7 +259,6 @@ TEST(CurlClientTest, ParsesMultiLineData) { auto client = Builder(runner.context().get_executor(), "http://localhost:" + std::to_string(port)) .receiver([&](Event e) { collector.add_event(std::move(e)); }) - .use_curl(true) .build(); client->async_connect(); @@ -299,7 +296,6 @@ TEST(CurlClientTest, HandlesComments) { auto client = Builder(runner.context().get_executor(), "http://localhost:" + std::to_string(port)) .receiver([&](Event e) { collector.add_event(std::move(e)); }) - .use_curl(true) .build(); client->async_connect(); @@ -341,7 +337,6 @@ TEST(CurlClientTest, SupportsPostMethod) { .receiver([&](Event e) { collector.add_event(std::move(e)); }) .method(http::verb::post) .body("test body") - .use_curl(true) .build(); client->async_connect(); @@ -378,7 +373,6 @@ TEST(CurlClientTest, SupportsReportMethod) { .receiver([&](Event e) { collector.add_event(std::move(e)); }) .method(http::verb::report) .body("test body") - .use_curl(true) .build(); client->async_connect(); @@ -419,7 +413,6 @@ TEST(CurlClientTest, SendsCustomHeaders) { auto client = Builder(runner.context().get_executor(), "http://localhost:" + std::to_string(port)) .receiver([&](Event e) { collector.add_event(std::move(e)); }) .header("X-Custom-Header", "custom-value") - .use_curl(true) .build(); client->async_connect(); @@ -444,7 +437,6 @@ TEST(CurlClientTest, Handles404Error) { auto client = Builder(runner.context().get_executor(), "http://localhost:" + std::to_string(port)) .receiver([&](Event e) { collector.add_event(std::move(e)); }) .errors([&](Error e) { collector.add_error(std::move(e)); }) - .use_curl(true) .build(); client->async_connect(); @@ -481,7 +473,6 @@ TEST(CurlClientTest, Handles500Error) { .receiver([&](Event e) { collector.add_event(std::move(e)); }) .errors([&](Error e) { collector.add_error(std::move(e)); }) .initial_reconnect_delay(50ms) // Short delay for test - .use_curl(true) .build(); client->async_connect(); @@ -517,7 +508,6 @@ TEST(CurlClientTest, FollowsRedirects) { auto client = Builder(runner.context().get_executor(), "http://localhost:" + std::to_string(redirect_port)) .receiver([&](Event e) { collector.add_event(std::move(e)); }) - .use_curl(true) .build(); client->async_connect(); @@ -554,7 +544,6 @@ TEST(CurlClientTest, ShutdownStopsClient) { auto client = Builder(runner.context().get_executor(), "http://localhost:" + std::to_string(port)) .receiver([&](Event e) { collector.add_event(std::move(e)); }) - .use_curl(true) .build(); client->async_connect(); @@ -582,7 +571,6 @@ TEST(CurlClientTest, CanShutdownBeforeConnection) { auto client = Builder(runner.context().get_executor(), "http://localhost:" + std::to_string(port)) .receiver([&](Event e) { collector.add_event(std::move(e)); }) - .use_curl(true) .build(); // Shutdown immediately without connecting @@ -611,7 +599,6 @@ TEST(CurlClientTest, HandlesImmediateClose) { .receiver([&](Event e) { collector.add_event(std::move(e)); }) .errors([&](Error e) { collector.add_error(std::move(e)); }) .initial_reconnect_delay(50ms) // Short delay for test - .use_curl(true) .build(); client->async_connect(); @@ -656,7 +643,6 @@ TEST(CurlClientTest, RespectsReadTimeout) { .errors([&](Error e) { collector.add_error(std::move(e)); }) .read_timeout(500ms) // Short timeout for test .initial_reconnect_delay(50ms) - .use_curl(true) .build(); client->async_connect(); @@ -687,8 +673,7 @@ TEST(CurlClientTest, NoThreadLeaksAfterMultipleConnections) { auto client = Builder(runner.context().get_executor(), "http://localhost:" + std::to_string(port)) .receiver([&](Event e) { collector.add_event(std::move(e)); }) - .use_curl(true) - .build(); + .build(); client->async_connect(); ASSERT_TRUE(collector.wait_for_events(1)); @@ -725,8 +710,7 @@ TEST(CurlClientTest, DestructorCleansUpProperly) { { auto client = Builder(runner.context().get_executor(), "http://localhost:" + std::to_string(port)) .receiver([&](Event e) { collector.add_event(std::move(e)); }) - .use_curl(true) - .build(); + .build(); client->async_connect(); ASSERT_TRUE(collector.wait_for_events(1)); @@ -758,7 +742,6 @@ TEST(CurlClientTest, HandlesEmptyEventData) { auto client = Builder(runner.context().get_executor(), "http://localhost:" + std::to_string(port)) .receiver([&](Event e) { collector.add_event(std::move(e)); }) - .use_curl(true) .build(); client->async_connect(); @@ -792,7 +775,6 @@ TEST(CurlClientTest, HandlesEventWithOnlyType) { auto client = Builder(runner.context().get_executor(), "http://localhost:" + std::to_string(port)) .receiver([&](Event e) { collector.add_event(std::move(e)); }) - .use_curl(true) .build(); client->async_connect(); @@ -831,7 +813,6 @@ TEST(CurlClientTest, HandlesRapidEvents) { auto client = Builder(runner.context().get_executor(), "http://localhost:" + std::to_string(port)) .receiver([&](Event e) { collector.add_event(std::move(e)); }) - .use_curl(true) .build(); client->async_connect(); @@ -870,7 +851,6 @@ TEST(CurlClientTest, ShutdownDuringBackoffDelay) { auto client = Builder(runner.context().get_executor(), "http://localhost:" + std::to_string(port)) .receiver([&](Event e) { collector.add_event(std::move(e)); }) .initial_reconnect_delay(2000ms) // Long delay to ensure we shutdown during wait - .use_curl(true) .build(); client->async_connect(); @@ -930,7 +910,6 @@ TEST(CurlClientTest, ShutdownDuringDataReception) { client_received_some.count_down(); } }) - .use_curl(true) .build(); client->async_connect(); @@ -977,7 +956,6 @@ TEST(CurlClientTest, ShutdownDuringProgressCallback) { auto client = Builder(runner.context().get_executor(), "http://localhost:" + std::to_string(port)) .receiver([&](Event e) { collector.add_event(std::move(e)); }) .read_timeout(10000ms) // Long timeout so ProgressCallback is called but doesn't abort - .use_curl(true) .build(); client->async_connect(); @@ -1007,7 +985,6 @@ TEST(CurlClientTest, MultipleShutdownCalls) { auto client = Builder(runner.context().get_executor(), "http://localhost:" + std::to_string(port)) .receiver([&](Event e) { collector.add_event(std::move(e)); }) - .use_curl(true) .build(); client->async_connect(); @@ -1048,7 +1025,6 @@ TEST(CurlClientTest, ShutdownAfterConnectionClosed) { auto client = Builder(runner.context().get_executor(), "http://localhost:" + std::to_string(port)) .receiver([&](Event e) { collector.add_event(std::move(e)); }) .initial_reconnect_delay(500ms) // Will try to reconnect after close - .use_curl(true) .build(); client->async_connect(); @@ -1091,7 +1067,6 @@ TEST(CurlClientTest, ShutdownDuringConnectionAttempt) { auto client = Builder(runner.context().get_executor(), "http://localhost:" + std::to_string(port)) .receiver([&](Event e) { collector.add_event(std::move(e)); }) - .use_curl(true) .build(); client->async_connect(); @@ -1112,3 +1087,4 @@ TEST(CurlClientTest, ShutdownDuringConnectionAttempt) { // Should not have received any events since we shutdown during connection EXPECT_EQ(0, collector.events().size()); } +#endif // LD_CURL_NETWORKING From 428af2240a65698960a321a82fb82a24514aaade Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 13 Oct 2025 14:48:47 -0700 Subject: [PATCH 31/90] Add proxy validation test. --- examples/proxy-validation-test/.dockerignore | 13 ++ examples/proxy-validation-test/Dockerfile | 38 +++++ examples/proxy-validation-test/README.md | 155 ++++++++++++++++++ .../proxy-validation-test/docker-compose.yml | 45 +++++ examples/proxy-validation-test/test-proxy.sh | 102 ++++++++++++ 5 files changed, 353 insertions(+) create mode 100644 examples/proxy-validation-test/.dockerignore create mode 100644 examples/proxy-validation-test/Dockerfile create mode 100644 examples/proxy-validation-test/README.md create mode 100644 examples/proxy-validation-test/docker-compose.yml create mode 100755 examples/proxy-validation-test/test-proxy.sh diff --git a/examples/proxy-validation-test/.dockerignore b/examples/proxy-validation-test/.dockerignore new file mode 100644 index 000000000..b2b5e021e --- /dev/null +++ b/examples/proxy-validation-test/.dockerignore @@ -0,0 +1,13 @@ +build/ +build-*/ +.git/ +.github/ +*.pyc +__pycache__/ +.DS_Store +*.swp +*.swo +*~ +.vscode/ +.idea/ +cmake-build-*/ diff --git a/examples/proxy-validation-test/Dockerfile b/examples/proxy-validation-test/Dockerfile new file mode 100644 index 000000000..e913d77fe --- /dev/null +++ b/examples/proxy-validation-test/Dockerfile @@ -0,0 +1,38 @@ +FROM ubuntu:24.04 + +# Install dependencies +RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y \ + build-essential \ + cmake \ + ninja-build \ + libboost-all-dev \ + libssl-dev \ + libcurl4-openssl-dev \ + git \ + curl \ + netcat-openbsd \ + && rm -rf /var/lib/apt/lists/* + +# Set working directory +WORKDIR /sdk + +# Copy SDK source +COPY . . + +# Clean any existing build directory from host +RUN rm -rf build + +# Build the SDK with CURL networking enabled +RUN cmake -B build -S . -GNinja \ + -DCMAKE_BUILD_TYPE=Release \ + -DLD_BUILD_SHARED_LIBS=OFF \ + -DLD_BUILD_EXAMPLES=ON \ + -DLD_BUILD_UNIT_TESTS=OFF \ + -DLD_CURL_NETWORKING=ON \ + && cmake --build build --target hello-cpp-client + +COPY examples/proxy-validation-test/test-proxy.sh /test-proxy.sh +RUN chmod +x /test-proxy.sh + +# Set entrypoint +ENTRYPOINT ["/test-proxy.sh"] diff --git a/examples/proxy-validation-test/README.md b/examples/proxy-validation-test/README.md new file mode 100644 index 000000000..76e0877bc --- /dev/null +++ b/examples/proxy-validation-test/README.md @@ -0,0 +1,155 @@ +# Proxy Validation Test + +This test validates that the LaunchDarkly C++ Client SDK properly uses CURL for all network requests and correctly routes traffic through a proxy when configured. + +## Prerequisites + +- Docker and Docker Compose +- A LaunchDarkly mobile key (for testing with real endpoints) + +## What This Test Validates + +1. **SSE streaming** connections go through the configured proxy +2. **Event posting** goes through the configured proxy +3. **Without proxy access**, the SDK cannot reach LaunchDarkly servers +4. **With proxy access**, all SDK operations work correctly + +Polling can be validated by changing the hello-cpp-client application to use polling. + +## Test Architecture + +The test uses Docker Compose with network isolation to validate proxy functionality: + +- **proxy**: SOCKS5 proxy server (using `serjs/go-socks5-proxy`) + - Connected to both the internal network and the internet + - Provides authenticated SOCKS5 proxy on port 1080 + +- **client**: SDK test application + - Only connected to the internal network (no direct internet access) + - Must route all traffic through the proxy to reach LaunchDarkly + +The client container is on an isolated Docker network (`internal: true`), which physically blocks direct internet access. This proves that the SDK must use the configured proxy to communicate with LaunchDarkly. + +## Running the Test + +```bash +# Set your LaunchDarkly mobile key +export LD_MOBILE_KEY="your-mobile-key-here" + +# Run the test +docker compose up --build +``` + +**Note:** The Dockerfile uses Ubuntu 24.04 to ensure Boost 1.81+ is available. + +## Supported Proxy Types + +The SDK (via CURL) supports: +- HTTP proxies: `http://proxy:port` +- HTTPS proxies: `https://proxy:port` +- SOCKS4 proxies: `socks4://proxy:port` +- SOCKS5 proxies: `socks5://proxy:port` +- SOCKS5 with auth: `socks5://user:pass@proxy:port` +- SOCKS5 with hostname resolution: `socks5h://user:pass@proxy:port` + +## Environment Variables + +The test configures the following environment variables: + +- `ALL_PROXY` - Proxy for all protocols (set to `socks5h://proxyuser:proxypass@proxy:1080`) +- `LD_MOBILE_KEY` - LaunchDarkly mobile key for testing +- `LD_LOG_LEVEL` - Set to `debug` for verbose logging + +CURL also respects: +- `HTTP_PROXY` - Proxy for HTTP requests +- `HTTPS_PROXY` - Proxy for HTTPS requests +- `NO_PROXY` - Comma-separated list of hosts to bypass proxy + +## Expected Output + +### Success Case +``` +Test 1: Verifying proxy connectivity... +✓ Proxy is reachable at proxy:1080 + +Test 2: Verifying direct access to LaunchDarkly is blocked... +✓ Direct access blocked (network is properly isolated) + +Test 3: Verifying proxy can reach LaunchDarkly... +✓ Proxy can reach LaunchDarkly endpoints + +Test 4: Running SDK client with proxy... +*** SDK successfully initialized! +*** Feature flag 'my-boolean-flag' is true for this user + +✓ SDK successfully initialized through proxy! +✓ Flag evaluation succeeded + +================================ +✓ ALL TESTS PASSED + +The SDK successfully: + - Connected through the SOCKS5 proxy + - Established SSE streaming connection + - Retrieved feature flag values + - Posted analytics events (if enabled) +``` + +### Failure Case (no proxy access) +``` +Test 2: Verifying direct access to LaunchDarkly is blocked... +✓ Direct access blocked (network is properly isolated) + +Test 3: Verifying proxy can reach LaunchDarkly... +✗ Proxy cannot reach LaunchDarkly endpoints +``` + +## Purpose of the test + +The test validates proxy functionality through network isolation: + +1. **Network Architecture**: The client container is on an isolated Docker network (`internal: true`) that blocks all external connections +2. **Direct Access Test**: With proxy variables unset, `curl` cannot reach LaunchDarkly (proves isolation) +3. **SDK Success**: The SDK successfully connects when `ALL_PROXY` is configured (proves proxy usage) + +Since the client cannot reach the internet directly, the SDK MUST be using the proxy to succeed. + +## Troubleshooting + +### Enable CURL verbose logging + +Set the `CURL_VERBOSE` environment variable in `docker-compose.yml`: +```yaml +environment: + - CURL_VERBOSE=1 +``` + +### Check proxy logs + +```bash +docker compose logs proxy +``` + +You should see multiple SOCKS5 connections from the SDK client: +``` +[INFO] socks: Connection from 172.18.0.3 to clientsdk.launchdarkly.com +[INFO] socks: Connection from 172.18.0.3 to events.launchdarkly.com +``` + +### Verify SDK is using proxy + +The best proof is in the proxy logs. Each SDK network operation creates a connection: +- Initial SSE stream connection +- Event delivery (if enabled) + +### Verify network isolation + +```bash +# This should fail (timeout) because the client has no direct internet access +docker compose run client sh -c "unset ALL_PROXY && curl -v https://clientsdk.launchdarkly.com" +# Expected: Connection timeout or refused +``` + +### Test fails with DNS errors + +If you see DNS resolution errors, ensure you're using `socks5h://` instead of `socks5://`. The `socks5h` protocol performs DNS resolution through the proxy, which is necessary when the client is on an isolated network. diff --git a/examples/proxy-validation-test/docker-compose.yml b/examples/proxy-validation-test/docker-compose.yml new file mode 100644 index 000000000..4345c3a19 --- /dev/null +++ b/examples/proxy-validation-test/docker-compose.yml @@ -0,0 +1,45 @@ +version: '3.8' + +services: + # SOCKS5 proxy server - connected to BOTH internal network and internet + proxy: + image: serjs/go-socks5-proxy:latest + container_name: ld-proxy-test + environment: + - PROXY_USER=proxyuser + - PROXY_PASSWORD=proxypass + - PROXY_PORT=1080 + ports: + - "1080:1080" + networks: + - internal # Can communicate with client + - default # Has internet access + + # SDK client application - ONLY on internal network (no direct internet) + client: + build: + context: ../.. + dockerfile: examples/proxy-validation-test/Dockerfile + container_name: ld-client-test + depends_on: + - proxy + # Give proxy a few seconds to start + command: sh -c "sleep 5 && /test-proxy.sh" + environment: + - LD_MOBILE_KEY=${LD_MOBILE_KEY} + # Use socks5h:// to perform DNS resolution through the proxy + - ALL_PROXY=socks5h://proxyuser:proxypass@proxy:1080 + - LD_LOG_LEVEL=debug + networks: + - internal # Only internal network - no direct internet access! + dns: + - 8.8.8.8 + cap_drop: + - ALL + +networks: + # Internal network with no internet access + internal: + driver: bridge + internal: true # This blocks external access! + # Default network (has internet access) - only proxy uses this diff --git a/examples/proxy-validation-test/test-proxy.sh b/examples/proxy-validation-test/test-proxy.sh new file mode 100755 index 000000000..b5b3f7f40 --- /dev/null +++ b/examples/proxy-validation-test/test-proxy.sh @@ -0,0 +1,102 @@ +#!/bin/bash +set -e + +echo "==================================" +echo "LaunchDarkly SDK Proxy Test" +echo "==================================" +echo "" + +# Check if mobile key is set +if [ -z "$LD_MOBILE_KEY" ]; then + echo "ERROR: LD_MOBILE_KEY environment variable is not set" + echo "Please set your LaunchDarkly mobile key:" + echo " export LD_MOBILE_KEY='your-mobile-key-here'" + echo " docker-compose up" + exit 1 +fi + +echo "Mobile Key: ${LD_MOBILE_KEY:0:10}..." +echo "Proxy: $ALL_PROXY" +echo "" + +# Test 1: Verify proxy is reachable +echo "Test 1: Verifying proxy connectivity..." +PROXY_HOST=$(echo $ALL_PROXY | sed 's/.*@//' | cut -d':' -f1) +PROXY_PORT=$(echo $ALL_PROXY | sed 's/.*://') + +if nc -z "$PROXY_HOST" "$PROXY_PORT" 2>/dev/null; then + echo "✓ Proxy is reachable at $PROXY_HOST:$PROXY_PORT" +else + echo "✗ Cannot reach proxy at $PROXY_HOST:$PROXY_PORT" + exit 1 +fi +echo "" + +# Test 2: Verify direct access is blocked (should fail) +echo "Test 2: Verifying direct access to LaunchDarkly is blocked..." +# Explicitly disable proxy for this test to check if direct access works +if timeout 5 env -u ALL_PROXY -u all_proxy -u HTTP_PROXY -u http_proxy -u HTTPS_PROXY -u https_proxy curl -s https://clientsdk.launchdarkly.com >/dev/null 2>&1; then + echo "⚠ WARNING: Direct access to LaunchDarkly succeeded (network is not isolated)" + echo " This means the client can reach the internet directly without the proxy." + echo " The test will still verify that the SDK uses the proxy when configured." +else + echo "✓ Direct access blocked (network is properly isolated)" +fi +echo "" + +# Test 3: Verify proxy access works (should succeed) +echo "Test 3: Verifying proxy can reach LaunchDarkly..." +if timeout 10 curl -s --proxy "$ALL_PROXY" https://clientsdk.launchdarkly.com >/dev/null 2>&1; then + echo "✓ Proxy can reach LaunchDarkly endpoints" +else + echo "✗ Proxy cannot reach LaunchDarkly endpoints" + exit 1 +fi +echo "" + +# Test 4: Run the SDK client +echo "Test 4: Running SDK client with proxy..." +echo "----------------------------------------" + +# Run the client and capture output +/sdk/build/examples/hello-cpp-client/hello-cpp-client 2>&1 | tee /tmp/client-output.log + +# Check if SDK initialized successfully +if grep -q "SDK successfully initialized" /tmp/client-output.log; then + echo "" + echo "✓ SDK successfully initialized through proxy!" + INIT_SUCCESS=1 +else + echo "" + echo "✗ SDK failed to initialize" + INIT_SUCCESS=0 +fi + +# Check for flag evaluation +if grep -q "Feature flag" /tmp/client-output.log; then + echo "✓ Flag evaluation succeeded" + FLAG_SUCCESS=1 +else + echo "✗ Flag evaluation failed" + FLAG_SUCCESS=0 +fi + +echo "" +echo "==================================" +echo "Test Summary" +echo "==================================" +if [ $INIT_SUCCESS -eq 1 ] && [ $FLAG_SUCCESS -eq 1 ]; then + echo "✓ ALL TESTS PASSED" + echo "" + echo "The SDK successfully:" + echo " - Connected through the SOCKS5 proxy" + echo " - Established SSE streaming connection" + echo " - Retrieved feature flag values" + echo " - Posted analytics events (if enabled)" + exit 0 +else + echo "✗ TESTS FAILED" + echo "" + echo "Check the output above for details." + exit 1 +fi From b8b7f5305fde0b655cf920b5b9fe27481ba1fb9e Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 13 Oct 2025 16:00:56 -0700 Subject: [PATCH 32/90] Add programmatic interface for setting proxy. --- examples/hello-c-client/main.c | 3 ++ examples/hello-cpp-client/main.cpp | 5 +- examples/hello-cpp-server/main.cpp | 5 +- examples/proxy-validation-test/README.md | 2 +- .../client_side/bindings/c/config/builder.h | 27 ++++++++++ libs/client-sdk/src/bindings/c/builder.cpp | 9 ++++ .../data_sources/streaming_data_source.cpp | 4 ++ .../builders/http_properties_builder.hpp | 16 ++++++ .../config/shared/built/http_properties.hpp | 53 +++++++++++++++++-- .../launchdarkly/config/shared/defaults.hpp | 6 ++- libs/common/src/CMakeLists.txt | 4 ++ libs/common/src/config/http_properties.cpp | 35 ++++++++++-- .../src/config/http_properties_builder.cpp | 12 ++++- libs/internal/src/network/curl_requester.cpp | 9 ++++ .../server_side/bindings/c/config/builder.h | 27 ++++++++++ libs/server-sdk/src/bindings/c/builder.cpp | 9 ++++ .../streaming/streaming_data_source.cpp | 4 ++ .../include/launchdarkly/sse/client.hpp | 21 ++++++++ libs/server-sent-events/src/client.cpp | 17 +++++- libs/server-sent-events/src/curl_client.cpp | 12 ++++- libs/server-sent-events/src/curl_client.hpp | 4 +- 21 files changed, 267 insertions(+), 17 deletions(-) diff --git a/examples/hello-c-client/main.c b/examples/hello-c-client/main.c index 83d9efd48..33d043637 100644 --- a/examples/hello-c-client/main.c +++ b/examples/hello-c-client/main.c @@ -32,6 +32,8 @@ int main() { LDClientConfigBuilder config_builder = LDClientConfigBuilder_New(mobile_key); + LDClientConfigBuilder_HttpProperties_Proxy(config_builder, "socks5h://puser:ppass@localhost:1080"); + LDClientConfig config = NULL; LDStatus config_status = LDClientConfigBuilder_Build(config_builder, &config); @@ -59,6 +61,7 @@ int main() { } else { printf("SDK initialization didn't complete in %dms\n", INIT_TIMEOUT_MILLISECONDS); + LDClientSDK_Free(client); return 1; } diff --git a/examples/hello-cpp-client/main.cpp b/examples/hello-cpp-client/main.cpp index 3ac7c99ea..b063dd04e 100644 --- a/examples/hello-cpp-client/main.cpp +++ b/examples/hello-cpp-client/main.cpp @@ -29,7 +29,10 @@ int main() { "variable.\n" "The value of MOBILE_KEY in main.c takes priority over LD_MOBILE_KEY."); - auto config = ConfigBuilder(mobile_key).Build(); + auto builder = ConfigBuilder(mobile_key); + builder.HttpProperties().Proxy("socks5h://puser:ppass@localhost:1080"); + auto config = builder.Build(); + if (!config) { std::cout << "error: config is invalid: " << config.error() << '\n'; return 1; diff --git a/examples/hello-cpp-server/main.cpp b/examples/hello-cpp-server/main.cpp index 3aaba377d..e3a3399a3 100644 --- a/examples/hello-cpp-server/main.cpp +++ b/examples/hello-cpp-server/main.cpp @@ -30,7 +30,10 @@ int main() { "variable.\n" "The value of SDK_KEY in main.c takes priority over LD_SDK_KEY."); - auto config = ConfigBuilder(sdk_key).Build(); + auto builder = ConfigBuilder(sdk_key); + builder.HttpProperties().Proxy("socks5h://puser:ppass@proxy:1080"); + auto config = builder.Build(); + if (!config) { std::cout << "error: config is invalid: " << config.error() << '\n'; return 1; diff --git a/examples/proxy-validation-test/README.md b/examples/proxy-validation-test/README.md index 76e0877bc..f22ecbe82 100644 --- a/examples/proxy-validation-test/README.md +++ b/examples/proxy-validation-test/README.md @@ -16,7 +16,7 @@ This test validates that the LaunchDarkly C++ Client SDK properly uses CURL for Polling can be validated by changing the hello-cpp-client application to use polling. -## Test Architecture +## Test Architecturegit The test uses Docker Compose with network isolation to validate proxy functionality: diff --git a/libs/client-sdk/include/launchdarkly/client_side/bindings/c/config/builder.h b/libs/client-sdk/include/launchdarkly/client_side/bindings/c/config/builder.h index 81c5cb64c..e36282a96 100644 --- a/libs/client-sdk/include/launchdarkly/client_side/bindings/c/config/builder.h +++ b/libs/client-sdk/include/launchdarkly/client_side/bindings/c/config/builder.h @@ -492,6 +492,33 @@ LDClientHttpPropertiesTlsBuilder_CustomCAFile( LDClientHttpPropertiesTlsBuilder b, char const* custom_ca_file); +/** + * Sets proxy configuration for HTTP requests. + * + * When using CURL networking (LD_CURL_NETWORKING=ON), this controls proxy + * behavior. The proxy URL takes precedence over environment variables + * (ALL_PROXY, HTTP_PROXY, HTTPS_PROXY). + * + * Supported proxy types (when CURL networking is enabled): + * - HTTP proxies: "http://proxy:port" + * - HTTPS proxies: "https://proxy:port" + * - SOCKS4 proxies: "socks4://proxy:port" + * - SOCKS5 proxies: "socks5://proxy:port" or "socks5://user:pass@proxy:port" + * - SOCKS5 with DNS through proxy: "socks5h://proxy:port" + * + * Passing an empty string explicitly disables proxy (overrides environment + * variables). + * + * When CURL networking is disabled, attempting to configure a non-empty proxy + * will cause the Build function to fail. + * + * @param b Client config builder. Must not be NULL. + * @param proxy_url Proxy URL or empty string to disable. Must not be NULL. + */ +LD_EXPORT(void) +LDClientConfigBuilder_HttpProperties_Proxy(LDClientConfigBuilder b, + char const* proxy_url); + /** * Disables the default SDK logging. * @param b Client config builder. Must not be NULL. diff --git a/libs/client-sdk/src/bindings/c/builder.cpp b/libs/client-sdk/src/bindings/c/builder.cpp index 7994207e4..898ce2417 100644 --- a/libs/client-sdk/src/bindings/c/builder.cpp +++ b/libs/client-sdk/src/bindings/c/builder.cpp @@ -352,6 +352,15 @@ LDClientHttpPropertiesTlsBuilder_Free(LDClientHttpPropertiesTlsBuilder b) { delete TO_TLS_BUILDER(b); } +LD_EXPORT(void) +LDClientConfigBuilder_HttpProperties_Proxy(LDClientConfigBuilder b, + char const* proxy_url) { + LD_ASSERT_NOT_NULL(b); + LD_ASSERT_NOT_NULL(proxy_url); + + TO_BUILDER(b)->HttpProperties().Proxy(proxy_url); +} + LD_EXPORT(void) LDClientConfigBuilder_Logging_Disable(LDClientConfigBuilder b) { LD_ASSERT_NOT_NULL(b); diff --git a/libs/client-sdk/src/data_sources/streaming_data_source.cpp b/libs/client-sdk/src/data_sources/streaming_data_source.cpp index d28b36677..83494a6a0 100644 --- a/libs/client-sdk/src/data_sources/streaming_data_source.cpp +++ b/libs/client-sdk/src/data_sources/streaming_data_source.cpp @@ -121,6 +121,10 @@ void StreamingDataSource::Start() { client_builder.custom_ca_file(*ca_file); } + if (auto proxy_url = http_config_.Proxy().Url()) { + client_builder.proxy(*proxy_url); + } + auto weak_self = weak_from_this(); client_builder.receiver([weak_self](launchdarkly::sse::Event const& event) { diff --git a/libs/common/include/launchdarkly/config/shared/builders/http_properties_builder.hpp b/libs/common/include/launchdarkly/config/shared/builders/http_properties_builder.hpp index dbe312df8..a6463b3aa 100644 --- a/libs/common/include/launchdarkly/config/shared/builders/http_properties_builder.hpp +++ b/libs/common/include/launchdarkly/config/shared/builders/http_properties_builder.hpp @@ -178,6 +178,21 @@ class HttpPropertiesBuilder { */ HttpPropertiesBuilder& Tls(TlsBuilder builder); + /** + * Sets proxy configuration for HTTP requests. + * + * When set, the proxy URL takes precedence over environment variables + * (ALL_PROXY, HTTP_PROXY, HTTPS_PROXY). + * + * @param url Proxy URL: + * - std::nullopt: Use environment variables (default) + * - Non-empty string: Use this proxy URL + * - Empty string: Explicitly disable proxy (overrides environment variables) + * @return A reference to this builder. + * @throws std::runtime_error if proxy is configured without CURL networking support + */ + HttpPropertiesBuilder& Proxy(std::optional url); + /** * Build a set of HttpProperties. * @return The built properties. @@ -193,6 +208,7 @@ class HttpPropertiesBuilder { std::string wrapper_version_; std::map base_headers_; TlsBuilder tls_; + built::ProxyOptions proxy_; }; } // namespace launchdarkly::config::shared::builders diff --git a/libs/common/include/launchdarkly/config/shared/built/http_properties.hpp b/libs/common/include/launchdarkly/config/shared/built/http_properties.hpp index a21e7143f..77b8b65b3 100644 --- a/libs/common/include/launchdarkly/config/shared/built/http_properties.hpp +++ b/libs/common/include/launchdarkly/config/shared/built/http_properties.hpp @@ -23,6 +23,50 @@ class TlsOptions final { std::optional ca_bundle_path_; }; +/** + * Proxy configuration for HTTP requests. + * + * When using CURL networking (LD_CURL_NETWORKING=ON), this controls proxy behavior: + * - std::nullopt (default): CURL uses environment variables (ALL_PROXY, HTTP_PROXY, HTTPS_PROXY) + * - Non-empty string: Explicitly configured proxy URL takes precedence over environment variables + * - Empty string: Explicitly disables proxy, preventing environment variable usage + * + * The empty string is forwarded to the networking implementation (CURL) which interprets + * it as "do not use any proxy, even if environment variables are set." + * + * When CURL networking is disabled, attempting to configure a proxy will throw an error. + */ +class ProxyOptions final { + public: + /** + * Construct proxy options with a proxy URL. + * + * @param url Proxy URL or configuration: + * - std::nullopt: Use environment variables (default) + * - "socks5://user:pass@proxy.example.com:1080": SOCKS5 proxy with auth + * - "socks5h://proxy:1080": SOCKS5 proxy with DNS resolution through proxy + * - "http://proxy.example.com:8080": HTTP proxy + * - "": Empty string explicitly disables proxy (overrides environment variables) + * + * @throws std::runtime_error if proxy URL is non-empty and CURL networking is not enabled + */ + explicit ProxyOptions(std::optional url); + + /** + * Default constructor. Uses environment variables for proxy configuration. + */ + ProxyOptions(); + + /** + * Get the configured proxy URL. + * @return Proxy URL if configured, or std::nullopt to use environment variables. + */ + [[nodiscard]] std::optional const& Url() const; + + private: + std::optional url_; +}; + class HttpProperties final { public: HttpProperties(std::chrono::milliseconds connect_timeout, @@ -30,7 +74,8 @@ class HttpProperties final { std::chrono::milliseconds write_timeout, std::chrono::milliseconds response_timeout, std::map base_headers, - TlsOptions tls); + TlsOptions tls, + ProxyOptions proxy); [[nodiscard]] std::chrono::milliseconds ConnectTimeout() const; [[nodiscard]] std::chrono::milliseconds ReadTimeout() const; @@ -41,6 +86,8 @@ class HttpProperties final { [[nodiscard]] TlsOptions const& Tls() const; + [[nodiscard]] ProxyOptions const& Proxy() const; + private: std::chrono::milliseconds connect_timeout_; std::chrono::milliseconds read_timeout_; @@ -48,11 +95,11 @@ class HttpProperties final { std::chrono::milliseconds response_timeout_; std::map base_headers_; TlsOptions tls_; - - // TODO: Proxy. + ProxyOptions proxy_; }; bool operator==(HttpProperties const& lhs, HttpProperties const& rhs); bool operator==(TlsOptions const& lhs, TlsOptions const& rhs); +bool operator==(ProxyOptions const& lhs, ProxyOptions const& rhs); } // namespace launchdarkly::config::shared::built diff --git a/libs/common/include/launchdarkly/config/shared/defaults.hpp b/libs/common/include/launchdarkly/config/shared/defaults.hpp index 0995eaa52..0fc0cb9d9 100644 --- a/libs/common/include/launchdarkly/config/shared/defaults.hpp +++ b/libs/common/include/launchdarkly/config/shared/defaults.hpp @@ -57,7 +57,8 @@ struct Defaults { std::chrono::seconds{10}, std::chrono::seconds{10}, std::map(), - TLS()}; + TLS(), + shared::built::ProxyOptions()}; } static auto StreamingConfig() -> shared::built::StreamingConfig { @@ -105,7 +106,8 @@ struct Defaults { std::chrono::seconds{10}, std::chrono::seconds{10}, std::map(), - TLS()}; + TLS(), + built::ProxyOptions()}; } static auto StreamingConfig() -> built::StreamingConfig { diff --git a/libs/common/src/CMakeLists.txt b/libs/common/src/CMakeLists.txt index b9a7ad287..b0a9446b2 100644 --- a/libs/common/src/CMakeLists.txt +++ b/libs/common/src/CMakeLists.txt @@ -91,6 +91,10 @@ target_include_directories(${LIBNAME} # Minimum C++ standard needed for consuming the public API is C++17. target_compile_features(${LIBNAME} PUBLIC cxx_std_17) +if (LD_CURL_NETWORKING) + target_compile_definitions(${LIBNAME} PUBLIC LD_CURL_NETWORKING) +endif() + install( TARGETS ${LIBNAME} EXPORT ${LD_TARGETS_EXPORT_NAME} diff --git a/libs/common/src/config/http_properties.cpp b/libs/common/src/config/http_properties.cpp index 07c4213ec..4e63f0c1e 100644 --- a/libs/common/src/config/http_properties.cpp +++ b/libs/common/src/config/http_properties.cpp @@ -1,3 +1,4 @@ +#include #include #include @@ -22,18 +23,37 @@ std::optional const& TlsOptions::CustomCAFile() const { return ca_bundle_path_; } +ProxyOptions::ProxyOptions(std::optional url) + : url_(std::move(url)) { +#ifndef LD_CURL_NETWORKING + if (url_.has_value() && !url_->empty()) { + throw std::runtime_error( + "Proxy configuration requires CURL networking support. " + "Please rebuild with -DLD_CURL_NETWORKING=ON"); + } +#endif +} + +ProxyOptions::ProxyOptions() : ProxyOptions(std::nullopt) {} + +std::optional const& ProxyOptions::Url() const { + return url_; +} + HttpProperties::HttpProperties(std::chrono::milliseconds connect_timeout, std::chrono::milliseconds read_timeout, std::chrono::milliseconds write_timeout, std::chrono::milliseconds response_timeout, std::map base_headers, - TlsOptions tls) + TlsOptions tls, + ProxyOptions proxy) : connect_timeout_(connect_timeout), read_timeout_(read_timeout), write_timeout_(write_timeout), response_timeout_(response_timeout), base_headers_(std::move(base_headers)), - tls_(std::move(tls)) {} + tls_(std::move(tls)), + proxy_(std::move(proxy)) {} std::chrono::milliseconds HttpProperties::ConnectTimeout() const { return connect_timeout_; @@ -59,11 +79,16 @@ TlsOptions const& HttpProperties::Tls() const { return tls_; } +ProxyOptions const& HttpProperties::Proxy() const { + return proxy_; +} + bool operator==(HttpProperties const& lhs, HttpProperties const& rhs) { return lhs.ReadTimeout() == rhs.ReadTimeout() && lhs.WriteTimeout() == rhs.WriteTimeout() && lhs.ConnectTimeout() == rhs.ConnectTimeout() && - lhs.BaseHeaders() == rhs.BaseHeaders() && lhs.Tls() == rhs.Tls(); + lhs.BaseHeaders() == rhs.BaseHeaders() && lhs.Tls() == rhs.Tls() && + lhs.Proxy() == rhs.Proxy(); } bool operator==(TlsOptions const& lhs, TlsOptions const& rhs) { @@ -71,4 +96,8 @@ bool operator==(TlsOptions const& lhs, TlsOptions const& rhs) { lhs.CustomCAFile() == rhs.CustomCAFile(); } +bool operator==(ProxyOptions const& lhs, ProxyOptions const& rhs) { + return lhs.Url() == rhs.Url(); +} + } // namespace launchdarkly::config::shared::built diff --git a/libs/common/src/config/http_properties_builder.cpp b/libs/common/src/config/http_properties_builder.cpp index 836eeb397..df5b31c65 100644 --- a/libs/common/src/config/http_properties_builder.cpp +++ b/libs/common/src/config/http_properties_builder.cpp @@ -51,6 +51,7 @@ HttpPropertiesBuilder::HttpPropertiesBuilder( response_timeout_ = properties.ResponseTimeout(); base_headers_ = properties.BaseHeaders(); tls_ = properties.Tls(); + proxy_ = properties.Proxy(); } template @@ -121,6 +122,13 @@ HttpPropertiesBuilder& HttpPropertiesBuilder::Tls( return *this; } +template +HttpPropertiesBuilder& HttpPropertiesBuilder::Proxy( + std::optional url) { + proxy_ = built::ProxyOptions(std::move(url)); + return *this; +} + template built::HttpProperties HttpPropertiesBuilder::Build() const { if (!wrapper_name_.empty()) { @@ -128,10 +136,10 @@ built::HttpProperties HttpPropertiesBuilder::Build() const { headers_with_wrapper["X-LaunchDarkly-Wrapper"] = wrapper_name_ + "/" + wrapper_version_; return {connect_timeout_, read_timeout_, write_timeout_, - response_timeout_, headers_with_wrapper, tls_.Build()}; + response_timeout_, headers_with_wrapper, tls_.Build(), proxy_}; } return {connect_timeout_, read_timeout_, write_timeout_, - response_timeout_, base_headers_, tls_.Build()}; + response_timeout_, base_headers_, tls_.Build(), proxy_}; } template class TlsBuilder; diff --git a/libs/internal/src/network/curl_requester.cpp b/libs/internal/src/network/curl_requester.cpp index 7eb1c0cb3..7120a222f 100644 --- a/libs/internal/src/network/curl_requester.cpp +++ b/libs/internal/src/network/curl_requester.cpp @@ -184,6 +184,15 @@ void CurlRequester::PerformRequestStatic(net::any_io_executor ctx, TlsOptions co } } + // Set proxy if configured + // When proxy URL is set, it takes precedence over environment variables. + // Empty string explicitly disables proxy (overrides environment variables). + auto const& proxy_url = request.Properties().Proxy().Url(); + if (proxy_url.has_value()) { + curl_easy_setopt(curl, CURLOPT_PROXY, proxy_url->c_str()); + } + // If proxy URL is std::nullopt, CURL will use environment variables (default behavior) + // Set callbacks curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, WriteCallback); curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response_body); diff --git a/libs/server-sdk/include/launchdarkly/server_side/bindings/c/config/builder.h b/libs/server-sdk/include/launchdarkly/server_side/bindings/c/config/builder.h index 1e4cbba53..967d1ddca 100644 --- a/libs/server-sdk/include/launchdarkly/server_side/bindings/c/config/builder.h +++ b/libs/server-sdk/include/launchdarkly/server_side/bindings/c/config/builder.h @@ -484,6 +484,33 @@ LDServerHttpPropertiesTlsBuilder_CustomCAFile( LDServerHttpPropertiesTlsBuilder b, char const* custom_ca_file); +/** + * Sets proxy configuration for HTTP requests. + * + * When using CURL networking (LD_CURL_NETWORKING=ON), this controls proxy + * behavior. The proxy URL takes precedence over environment variables + * (ALL_PROXY, HTTP_PROXY, HTTPS_PROXY). + * + * Supported proxy types (when CURL networking is enabled): + * - HTTP proxies: "http://proxy:port" + * - HTTPS proxies: "https://proxy:port" + * - SOCKS4 proxies: "socks4://proxy:port" + * - SOCKS5 proxies: "socks5://proxy:port" or "socks5://user:pass@proxy:port" + * - SOCKS5 with DNS through proxy: "socks5h://proxy:port" + * + * Passing an empty string explicitly disables proxy (overrides environment + * variables). + * + * When CURL networking is disabled, attempting to configure a non-empty proxy + * will cause the Build function to fail. + * + * @param b Server config builder. Must not be NULL. + * @param proxy_url Proxy URL or empty string to disable. Must not be NULL. + */ +LD_EXPORT(void) +LDServerConfigBuilder_HttpProperties_Proxy(LDServerConfigBuilder b, + char const* proxy_url); + /** * Disables the default SDK logging. * @param b Server config builder. Must not be NULL. diff --git a/libs/server-sdk/src/bindings/c/builder.cpp b/libs/server-sdk/src/bindings/c/builder.cpp index 655113720..f6364e80c 100644 --- a/libs/server-sdk/src/bindings/c/builder.cpp +++ b/libs/server-sdk/src/bindings/c/builder.cpp @@ -396,6 +396,15 @@ LDServerHttpPropertiesTlsBuilder_Free(LDServerHttpPropertiesTlsBuilder b) { delete TO_TLS_BUILDER(b); } +LD_EXPORT(void) +LDServerConfigBuilder_HttpProperties_Proxy(LDServerConfigBuilder b, + char const* proxy_url) { + LD_ASSERT_NOT_NULL(b); + LD_ASSERT_NOT_NULL(proxy_url); + + TO_BUILDER(b)->HttpProperties().Proxy(proxy_url); +} + LD_EXPORT(void) LDServerConfigBuilder_Logging_Disable(LDServerConfigBuilder b) { LD_ASSERT_NOT_NULL(b); diff --git a/libs/server-sdk/src/data_systems/background_sync/sources/streaming/streaming_data_source.cpp b/libs/server-sdk/src/data_systems/background_sync/sources/streaming/streaming_data_source.cpp index 778eef153..4c433c12b 100644 --- a/libs/server-sdk/src/data_systems/background_sync/sources/streaming/streaming_data_source.cpp +++ b/libs/server-sdk/src/data_systems/background_sync/sources/streaming/streaming_data_source.cpp @@ -119,6 +119,10 @@ void StreamingDataSource::StartAsync( client_builder.custom_ca_file(*ca_file); } + if (auto proxy_url = http_config_.Proxy().Url()) { + client_builder.proxy(*proxy_url); + } + auto weak_self = weak_from_this(); client_builder.receiver([weak_self](launchdarkly::sse::Event const& event) { diff --git a/libs/server-sent-events/include/launchdarkly/sse/client.hpp b/libs/server-sent-events/include/launchdarkly/sse/client.hpp index 2bf93989c..ca0b93a7a 100644 --- a/libs/server-sent-events/include/launchdarkly/sse/client.hpp +++ b/libs/server-sent-events/include/launchdarkly/sse/client.hpp @@ -153,6 +153,26 @@ class Builder { */ Builder& custom_ca_file(std::string path); + /** + * Specify a proxy URL for the connection. When set, it takes precedence + * over environment variables (ALL_PROXY, HTTP_PROXY, HTTPS_PROXY). + * + * Supported proxy types (when CURL networking is enabled): + * - HTTP proxies: "http://proxy:port" + * - HTTPS proxies: "https://proxy:port" + * - SOCKS4 proxies: "socks4://proxy:port" + * - SOCKS5 proxies: "socks5://proxy:port" or "socks5://user:pass@proxy:port" + * - SOCKS5 with DNS through proxy: "socks5h://proxy:port" + * + * Passing an empty string explicitly disables proxy (overrides environment variables). + * Passing std::nullopt (or not calling this method) uses environment variables. + * + * @param url Proxy URL, empty string to disable, or std::nullopt for environment variables + * @return Reference to this builder. + * @throws std::runtime_error if proxy is configured without CURL networking support + */ + Builder& proxy(std::optional url); + /** * Builds a Client. The shared pointer is necessary to extend the lifetime * of the Client to encompass each asynchronous operation that it performs. @@ -174,6 +194,7 @@ class Builder { ErrorCallback error_cb_; bool skip_verify_peer_; std::optional custom_ca_file_; + std::optional proxy_url_; }; /** diff --git a/libs/server-sent-events/src/client.cpp b/libs/server-sent-events/src/client.cpp index 428439377..46246f3b5 100644 --- a/libs/server-sent-events/src/client.cpp +++ b/libs/server-sent-events/src/client.cpp @@ -516,7 +516,8 @@ Builder::Builder(net::any_io_executor ctx, std::string url) receiver_([](launchdarkly::sse::Event const&) {}), error_cb_([](auto err) {}), skip_verify_peer_(false), - custom_ca_file_(std::nullopt) { + custom_ca_file_(std::nullopt), + proxy_url_(std::nullopt) { request_.version(11); request_.set(http::field::user_agent, kDefaultUserAgent); request_.method(http::verb::get); @@ -588,6 +589,18 @@ Builder& Builder::custom_ca_file(std::string path) { return *this; } +Builder& Builder::proxy(std::optional url) { +#ifndef LD_CURL_NETWORKING + if (url.has_value() && !url->empty()) { + throw std::runtime_error( + "Proxy configuration requires CURL networking support. " + "Please rebuild with -DLD_CURL_NETWORKING=ON"); + } +#endif + proxy_url_ = std::move(url); + return *this; +} + std::shared_ptr Builder::build() { auto uri_components = boost::urls::parse_uri(url_); if (!uri_components) { @@ -636,7 +649,7 @@ std::shared_ptr Builder::build() { net::make_strand(executor_), request, host, service, connect_timeout_, read_timeout_, write_timeout_, initial_reconnect_delay_, receiver_, logging_cb_, error_cb_, - skip_verify_peer_, custom_ca_file_, use_https); + skip_verify_peer_, custom_ca_file_, use_https, proxy_url_); #else std::optional ssl; if (uri_components->scheme_id() == boost::urls::scheme::https) { diff --git a/libs/server-sent-events/src/curl_client.cpp b/libs/server-sent-events/src/curl_client.cpp index 1f363c657..cb7d2e003 100644 --- a/libs/server-sent-events/src/curl_client.cpp +++ b/libs/server-sent-events/src/curl_client.cpp @@ -37,7 +37,8 @@ CurlClient::CurlClient(boost::asio::any_io_executor executor, Builder::ErrorCallback errors, bool skip_verify_peer, std::optional custom_ca_file, - bool use_https) + bool use_https, + std::optional proxy_url) : host_(std::move(host)), port_(std::move(port)), @@ -51,6 +52,7 @@ CurlClient::CurlClient(boost::asio::any_io_executor executor, skip_verify_peer_(skip_verify_peer), custom_ca_file_(std::move(custom_ca_file)), use_https_(use_https), + proxy_url_(std::move(proxy_url)), backoff_(initial_reconnect_delay.value_or(kDefaultInitialReconnectDelay), kDefaultMaxBackoffDelay), last_event_id_(std::nullopt), @@ -231,6 +233,14 @@ struct curl_slist* CurlClient::setup_curl_options(CURL* curl) { } } + // Set proxy if configured + // When proxy_url_ is set, it takes precedence over environment variables. + // Empty string explicitly disables proxy (overrides environment variables). + if (proxy_url_) { + curl_easy_setopt(curl, CURLOPT_PROXY, proxy_url_->c_str()); + } + // If proxy_url_ is std::nullopt, CURL will use environment variables (default behavior) + // Set callbacks curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, WriteCallback); curl_easy_setopt(curl, CURLOPT_WRITEDATA, this); diff --git a/libs/server-sent-events/src/curl_client.hpp b/libs/server-sent-events/src/curl_client.hpp index eded302e5..bde96a49b 100644 --- a/libs/server-sent-events/src/curl_client.hpp +++ b/libs/server-sent-events/src/curl_client.hpp @@ -40,7 +40,8 @@ class CurlClient : public Client, Builder::ErrorCallback errors, bool skip_verify_peer, std::optional custom_ca_file, - bool use_https); + bool use_https, + std::optional proxy_url); ~CurlClient() override; @@ -83,6 +84,7 @@ class CurlClient : public Client, bool skip_verify_peer_; std::optional custom_ca_file_; bool use_https_; + std::optional proxy_url_; Backoff backoff_; From 7f8f1cefe0a12a91e3753f02b9fb485b988087a9 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 13 Oct 2025 16:23:20 -0700 Subject: [PATCH 33/90] Start CI work --- .github/actions/install-curl/action.yml | 63 +++++++++++++ .github/actions/sdk-release/action.yml | 117 ++++++++++++++++++++++-- .github/workflows/cmake.yml | 39 ++++++++ README.md | 2 +- scripts/build-release-windows.sh | 48 +++++++--- scripts/build-release.sh | 39 ++++++-- 6 files changed, 276 insertions(+), 32 deletions(-) create mode 100644 .github/actions/install-curl/action.yml diff --git a/.github/actions/install-curl/action.yml b/.github/actions/install-curl/action.yml new file mode 100644 index 000000000..96c1de15a --- /dev/null +++ b/.github/actions/install-curl/action.yml @@ -0,0 +1,63 @@ +name: Install CURL +description: 'Install CURL development libraries for all platforms.' + +outputs: + CURL_ROOT: + description: The location of the installed CURL. + value: ${{ steps.determine-root.outputs.CURL_ROOT }} + +runs: + using: composite + steps: + # Linux: Install via apt-get + - name: Install CURL for Ubuntu + if: runner.os == 'Linux' + id: apt-action + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y libcurl4-openssl-dev + echo "CURL_ROOT=/usr" >> $GITHUB_OUTPUT + + # macOS: Install via homebrew + - name: Install CURL for macOS + if: runner.os == 'macOS' + id: brew-action + shell: bash + run: | + brew install curl + echo "CURL_ROOT=$(brew --prefix curl)" >> $GITHUB_OUTPUT + + # Windows: Download pre-built binaries from curl.se + - name: Install CURL for Windows + if: runner.os == 'Windows' + id: windows-action + shell: bash + run: | + # Download CURL 8.16.0 with OpenSSL for Windows (MSVC build) + curl -L https://curl.se/windows/dl-8.16.0/curl-8.16.0-win64-msvc.zip -o curl.zip + + # Extract to C:\curl-install + 7z x curl.zip -oC:\curl-install + + # The archive extracts to a subdirectory named curl-8.16.0-win64-msvc + echo "CURL_ROOT=C:\\curl-install\\curl-8.16.0-win64-msvc" >> $GITHUB_OUTPUT + + - name: Determine root + id: determine-root + shell: bash + run: | + if [ ! -z "$ROOT_APT" ]; then + echo "CURL_ROOT=$ROOT_APT" >> $GITHUB_OUTPUT + echo Setting CURL_ROOT to "$ROOT_APT" + elif [ ! -z "$ROOT_BREW" ]; then + echo "CURL_ROOT=$ROOT_BREW" >> $GITHUB_OUTPUT + echo Setting CURL_ROOT to "$ROOT_BREW" + elif [ ! -z "$ROOT_WINDOWS" ]; then + echo "CURL_ROOT=$ROOT_WINDOWS" >> $GITHUB_OUTPUT + echo Setting CURL_ROOT to "$ROOT_WINDOWS" + fi + env: + ROOT_APT: ${{ steps.apt-action.outputs.CURL_ROOT }} + ROOT_BREW: ${{ steps.brew-action.outputs.CURL_ROOT }} + ROOT_WINDOWS: ${{ steps.windows-action.outputs.CURL_ROOT }} diff --git a/.github/actions/sdk-release/action.yml b/.github/actions/sdk-release/action.yml index 4be6dd782..fafc667ad 100644 --- a/.github/actions/sdk-release/action.yml +++ b/.github/actions/sdk-release/action.yml @@ -40,7 +40,10 @@ runs: - name: Install OpenSSL uses: ./.github/actions/install-openssl id: install-openssl - - name: Build Linux Artifacts + - name: Install CURL + uses: ./.github/actions/install-curl + id: install-curl + - name: Build Linux Artifacts (Boost.Beast) if: runner.os == 'Linux' shell: bash run: | @@ -53,6 +56,17 @@ runs: BOOST_ROOT: ${{ steps.install-boost.outputs.BOOST_ROOT }} OPENSSL_ROOT_DIR: ${{ steps.install-openssl.outputs.OPENSSL_ROOT_DIR }} + - name: Build Linux Artifacts (CURL) + if: runner.os == 'Linux' + shell: bash + run: | + ./scripts/build-release.sh ${{ inputs.sdk_cmake_target }} --with-curl + env: + WORKSPACE: ${{ inputs.sdk_path }} + BOOST_ROOT: ${{ steps.install-boost.outputs.BOOST_ROOT }} + OPENSSL_ROOT_DIR: ${{ steps.install-openssl.outputs.OPENSSL_ROOT_DIR }} + CURL_ROOT: ${{ steps.install-curl.outputs.CURL_ROOT }} + - name: Archive Release Linux - GCC/x64/Static if: runner.os == 'Linux' uses: thedoctor0/zip-release@0.7.1 @@ -69,19 +83,35 @@ runs: type: 'zip' filename: 'linux-gcc-x64-dynamic.zip' + - name: Archive Release Linux - GCC/x64/Static/CURL + if: runner.os == 'Linux' + uses: thedoctor0/zip-release@0.7.1 + with: + path: 'build-static-curl/release' + type: 'zip' + filename: 'linux-gcc-x64-static-curl.zip' + + - name: Archive Release Linux - GCC/x64/Dynamic/CURL + if: runner.os == 'Linux' + uses: thedoctor0/zip-release@0.7.1 + with: + path: 'build-dynamic-curl/release' + type: 'zip' + filename: 'linux-gcc-x64-dynamic-curl.zip' + - name: Hash Linux Build Artifacts for provenance if: runner.os == 'Linux' shell: bash id: hash-linux run: | - echo "hashes-linux=$(sha256sum linux-gcc-x64-static.zip linux-gcc-x64-dynamic.zip | base64 -w0)" >> "$GITHUB_OUTPUT" + echo "hashes-linux=$(sha256sum linux-gcc-x64-static.zip linux-gcc-x64-dynamic.zip linux-gcc-x64-static-curl.zip linux-gcc-x64-dynamic-curl.zip | base64 -w0)" >> "$GITHUB_OUTPUT" - name: Upload Linux Build Artifacts if: runner.os == 'Linux' shell: bash run: | ls - gh release upload ${{ inputs.tag_name }} linux-gcc-x64-static.zip linux-gcc-x64-dynamic.zip --clobber + gh release upload ${{ inputs.tag_name }} linux-gcc-x64-static.zip linux-gcc-x64-dynamic.zip linux-gcc-x64-static-curl.zip linux-gcc-x64-dynamic-curl.zip --clobber env: GH_TOKEN: ${{ inputs.github_token }} @@ -96,7 +126,7 @@ runs: if: runner.os == 'Windows' uses: ilammy/msvc-dev-cmd@v1 - - name: Build Windows Artifacts + - name: Build Windows Artifacts (Boost.Beast) if: runner.os == 'Windows' shell: bash env: @@ -105,6 +135,17 @@ runs: OPENSSL_ROOT_DIR: ${{ steps.install-openssl.outputs.OPENSSL_ROOT_DIR }} run: ./scripts/build-release-windows.sh ${{ inputs.sdk_cmake_target }} + - name: Build Windows Artifacts (CURL) + if: runner.os == 'Windows' + shell: bash + env: + Boost_DIR: ${{ steps.install-boost.outputs.Boost_DIR }} + BOOST_ROOT: ${{ steps.install-boost.outputs.BOOST_ROOT }} + OPENSSL_ROOT_DIR: ${{ steps.install-openssl.outputs.OPENSSL_ROOT_DIR }} + CURL_ROOT: ${{ steps.install-curl.outputs.CURL_ROOT }} + CMAKE_PREFIX_PATH: ${{ steps.install-curl.outputs.CURL_ROOT }} + run: ./scripts/build-release-windows.sh ${{ inputs.sdk_cmake_target }} --with-curl + - name: Archive Release Windows - MSVC/x64/Static if: runner.os == 'Windows' uses: thedoctor0/zip-release@0.7.1 @@ -137,23 +178,55 @@ runs: type: 'zip' filename: 'windows-msvc-x64-dynamic-debug.zip' + - name: Archive Release Windows - MSVC/x64/Static/CURL + if: runner.os == 'Windows' + uses: thedoctor0/zip-release@0.7.1 + with: + path: 'build-static-curl/release' + type: 'zip' + filename: 'windows-msvc-x64-static-curl.zip' + + - name: Archive Release Windows - MSVC/x64/Dynamic/CURL + if: runner.os == 'Windows' + uses: thedoctor0/zip-release@0.7.1 + with: + path: 'build-dynamic-curl/release' + type: 'zip' + filename: 'windows-msvc-x64-dynamic-curl.zip' + + - name: Archive Release Windows - MSVC/x64/Static/Debug/CURL + if: runner.os == 'Windows' + uses: thedoctor0/zip-release@0.7.1 + with: + path: 'build-static-debug-curl/release' + type: 'zip' + filename: 'windows-msvc-x64-static-debug-curl.zip' + + - name: Archive Release Windows - MSVC/x64/Dynamic/Debug/CURL + if: runner.os == 'Windows' + uses: thedoctor0/zip-release@0.7.1 + with: + path: 'build-dynamic-debug-curl/release' + type: 'zip' + filename: 'windows-msvc-x64-dynamic-debug-curl.zip' + - name: Hash Windows Build Artifacts for provenance if: runner.os == 'Windows' shell: bash id: hash-windows run: | - echo "hashes-windows=$(sha256sum windows-msvc-x64-static.zip windows-msvc-x64-dynamic.zip windows-msvc-x64-static-debug.zip windows-msvc-x64-dynamic-debug.zip | base64 -w0)" >> "$GITHUB_OUTPUT" + echo "hashes-windows=$(sha256sum windows-msvc-x64-static.zip windows-msvc-x64-dynamic.zip windows-msvc-x64-static-debug.zip windows-msvc-x64-dynamic-debug.zip windows-msvc-x64-static-curl.zip windows-msvc-x64-dynamic-curl.zip windows-msvc-x64-static-debug-curl.zip windows-msvc-x64-dynamic-debug-curl.zip | base64 -w0)" >> "$GITHUB_OUTPUT" - name: Upload Windows Build Artifacts if: runner.os == 'Windows' shell: bash run: | ls - gh release upload ${{ inputs.tag_name }} windows-msvc-x64-static.zip windows-msvc-x64-dynamic.zip windows-msvc-x64-static-debug.zip windows-msvc-x64-dynamic-debug.zip --clobber + gh release upload ${{ inputs.tag_name }} windows-msvc-x64-static.zip windows-msvc-x64-dynamic.zip windows-msvc-x64-static-debug.zip windows-msvc-x64-dynamic-debug.zip windows-msvc-x64-static-curl.zip windows-msvc-x64-dynamic-curl.zip windows-msvc-x64-static-debug-curl.zip windows-msvc-x64-dynamic-debug-curl.zip --clobber env: GH_TOKEN: ${{ inputs.github_token }} - - name: Build Mac Artifacts + - name: Build Mac Artifacts (Boost.Beast) id: brew-action if: runner.os == 'macOS' shell: bash @@ -163,6 +236,16 @@ runs: BOOST_ROOT: ${{ steps.install-boost.outputs.BOOST_ROOT }} OPENSSL_ROOT_DIR: ${{ steps.install-openssl.outputs.OPENSSL_ROOT_DIR }} + - name: Build Mac Artifacts (CURL) + if: runner.os == 'macOS' + shell: bash + run: ./scripts/build-release.sh ${{ inputs.sdk_cmake_target }} --with-curl + env: + WORKSPACE: ${{ inputs.sdk_path }} + BOOST_ROOT: ${{ steps.install-boost.outputs.BOOST_ROOT }} + OPENSSL_ROOT_DIR: ${{ steps.install-openssl.outputs.OPENSSL_ROOT_DIR }} + CURL_ROOT: ${{ steps.install-curl.outputs.CURL_ROOT }} + - name: Archive Release Mac - AppleClang/x64/Static if: runner.os == 'macOS' uses: thedoctor0/zip-release@0.7.1 @@ -179,18 +262,34 @@ runs: type: 'zip' filename: 'mac-clang-x64-dynamic.zip' + - name: Archive Release Mac - AppleClang/x64/Static/CURL + if: runner.os == 'macOS' + uses: thedoctor0/zip-release@0.7.1 + with: + path: 'build-static-curl/release' + type: 'zip' + filename: 'mac-clang-x64-static-curl.zip' + + - name: Archive Release Mac - AppleClang/x64/Dynamic/CURL + if: runner.os == 'macOS' + uses: thedoctor0/zip-release@0.7.1 + with: + path: 'build-dynamic-curl/release' + type: 'zip' + filename: 'mac-clang-x64-dynamic-curl.zip' + - name: Hash Mac Build Artifacts for provenance if: runner.os == 'macOS' shell: bash id: hash-macos run: | - echo "hashes-macos=$(shasum -a 256 mac-clang-x64-static.zip mac-clang-x64-dynamic.zip | base64 -b 0)" >> "$GITHUB_OUTPUT" + echo "hashes-macos=$(shasum -a 256 mac-clang-x64-static.zip mac-clang-x64-dynamic.zip mac-clang-x64-static-curl.zip mac-clang-x64-dynamic-curl.zip | base64 -b 0)" >> "$GITHUB_OUTPUT" - name: Upload Mac Build Artifacts if: runner.os == 'macOS' shell: bash run: | ls - gh release upload ${{ inputs.tag_name }} mac-clang-x64-static.zip mac-clang-x64-dynamic.zip --clobber + gh release upload ${{ inputs.tag_name }} mac-clang-x64-static.zip mac-clang-x64-dynamic.zip mac-clang-x64-static-curl.zip mac-clang-x64-dynamic-curl.zip --clobber env: GH_TOKEN: ${{ inputs.github_token }} diff --git a/.github/workflows/cmake.yml b/.github/workflows/cmake.yml index 91ca97379..4625ad998 100644 --- a/.github/workflows/cmake.yml +++ b/.github/workflows/cmake.yml @@ -22,6 +22,17 @@ jobs: with: platform_version: '22.04' + test-ubuntu-curl: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + - name: Install CURL + run: sudo apt-get update && sudo apt-get install -y libcurl4-openssl-dev + - uses: ./.github/actions/cmake-test + with: + platform_version: '22.04' + cmake_extra_args: '-DLD_CURL_NETWORKING=ON' + test-macos: runs-on: macos-13 steps: @@ -30,6 +41,17 @@ jobs: with: platform_version: '12' + test-macos-curl: + runs-on: macos-13 + steps: + - uses: actions/checkout@v4 + - name: Install CURL + run: brew install curl + - uses: ./.github/actions/cmake-test + with: + platform_version: '12' + cmake_extra_args: '-DLD_CURL_NETWORKING=ON' + test-windows: runs-on: windows-2022 steps: @@ -41,3 +63,20 @@ jobs: with: platform_version: 2022 toolset: msvc + + test-windows-curl: + runs-on: windows-2022 + steps: + - uses: actions/checkout@v4 + - uses: ilammy/msvc-dev-cmd@v1 + - uses: ./.github/actions/install-curl + id: install-curl + - uses: ./.github/actions/cmake-test + env: + Boost_DIR: 'C:\local\boost_1_87_0\lib64-msvc-14.3\cmake\Boost-1.87.0' + CURL_ROOT: ${{ steps.install-curl.outputs.CURL_ROOT }} + CMAKE_PREFIX_PATH: ${{ steps.install-curl.outputs.CURL_ROOT }} + with: + platform_version: 2022 + toolset: msvc + cmake_extra_args: '-DLD_CURL_NETWORKING=ON' diff --git a/README.md b/README.md index 668f05e14..4939963d3 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ Various CMake options are available to customize the client/server SDK builds. | `LD_DYNAMIC_LINK_BOOST` | If building SDK as shared lib, whether to dynamically link Boost or not. Ensure that the shared boost libraries are present on the target system. | On (link boost dynamically when producing shared libs) | `LD_BUILD_SHARED_LIBS` | | `LD_DYNAMIC_LINK_OPENSSL` | Whether OpenSSL is dynamically linked or not. | Off (static link) | N/A | | `LD_BUILD_REDIS_SUPPORT` | Whether the server-side Redis Source is built or not. | Off | N/A | -| `LD_CURL_NETWORKING` | Enable CURL-based networking for all HTTP requests (SSE streams and event delivery). When OFF, Boost.Beast/Foxy is used instead. CURL must be available as a dependency when this option is OFF. | On | N/A | +| `LD_CURL_NETWORKING` | Enable CURL-based networking for all HTTP requests (SSE streams and event delivery). When OFF, Boost.Beast/Foxy is used instead. CURL must be available as a dependency when this option is ON. | Off | N/A | > [!WARNING] > When building shared libraries C++ symbols are not exported, only the C API will be exported. This is because C++ does diff --git a/scripts/build-release-windows.sh b/scripts/build-release-windows.sh index 9ad5f3c1d..5f5d5427f 100755 --- a/scripts/build-release-windows.sh +++ b/scripts/build-release-windows.sh @@ -1,65 +1,89 @@ #!/bin/bash -e -# Call this script with a CMakeTarget -# ./scripts/build-release launchdarkly-cpp-client +# Call this script with a CMakeTarget and optional flags +# ./scripts/build-release-windows.sh launchdarkly-cpp-client +# ./scripts/build-release-windows.sh launchdarkly-cpp-client --with-curl set -e +# Parse arguments +TARGET="$1" +build_redis="OFF" +build_curl="OFF" + # Special case: unlike the other targets, enabling redis support will pull in redis++ and hiredis dependencies at # configuration time. To ensure this only happens when asked, disable the support by default. -build_redis="OFF" -if [ "$1" == "launchdarkly-cpp-server-redis-source" ]; then +if [ "$TARGET" == "launchdarkly-cpp-server-redis-source" ]; then build_redis="ON" fi +# Check for --with-curl flag +for arg in "$@"; do + if [ "$arg" == "--with-curl" ]; then + build_curl="ON" + break + fi +done + +# Determine suffix for build directories +if [ "$build_curl" == "ON" ]; then + suffix="-curl" +else + suffix="" +fi + # Build a static release. -mkdir -p build-static && cd build-static +mkdir -p "build-static${suffix}" && cd "build-static${suffix}" mkdir -p release cmake -G Ninja -D CMAKE_BUILD_TYPE=Release \ -D LD_BUILD_REDIS_SUPPORT="$build_redis" \ + -D LD_CURL_NETWORKING="$build_curl" \ -D BUILD_TESTING=OFF \ -D CMAKE_INSTALL_PREFIX=./release .. -cmake --build . --target "$1" +cmake --build . --target "$TARGET" cmake --install . cd .. # Build a dynamic release. -mkdir -p build-dynamic && cd build-dynamic +mkdir -p "build-dynamic${suffix}" && cd "build-dynamic${suffix}" mkdir -p release cmake -G Ninja -D CMAKE_BUILD_TYPE=Release \ -D LD_BUILD_REDIS_SUPPORT="$build_redis" \ + -D LD_CURL_NETWORKING="$build_curl" \ -D BUILD_TESTING=OFF \ -D LD_BUILD_SHARED_LIBS=ON \ -D LD_DYNAMIC_LINK_BOOST=OFF \ -D CMAKE_INSTALL_PREFIX=./release .. -cmake --build . --target "$1" +cmake --build . --target "$TARGET" cmake --install . cd .. # Build a static debug release. -mkdir -p build-static-debug && cd build-static-debug +mkdir -p "build-static-debug${suffix}" && cd "build-static-debug${suffix}" mkdir -p release cmake -G Ninja -D CMAKE_BUILD_TYPE=Debug \ -D BUILD_TESTING=OFF \ -D LD_BUILD_REDIS_SUPPORT="$build_redis" \ + -D LD_CURL_NETWORKING="$build_curl" \ -D CMAKE_INSTALL_PREFIX=./release .. -cmake --build . --target "$1" +cmake --build . --target "$TARGET" cmake --install . cd .. # Build a dynamic debug release. -mkdir -p build-dynamic-debug && cd build-dynamic-debug +mkdir -p "build-dynamic-debug${suffix}" && cd "build-dynamic-debug${suffix}" mkdir -p release cmake -G Ninja -D CMAKE_BUILD_TYPE=Debug \ -D BUILD_TESTING=OFF \ -D LD_BUILD_REDIS_SUPPORT="$build_redis" \ + -D LD_CURL_NETWORKING="$build_curl" \ -D LD_BUILD_SHARED_LIBS=ON \ -D LD_DYNAMIC_LINK_BOOST=OFF \ -D CMAKE_INSTALL_PREFIX=./release .. -cmake --build . --target "$1" +cmake --build . --target "$TARGET" cmake --install . diff --git a/scripts/build-release.sh b/scripts/build-release.sh index 7d24f754e..38332ecf6 100755 --- a/scripts/build-release.sh +++ b/scripts/build-release.sh @@ -1,32 +1,51 @@ #!/bin/bash -e -# Call this script with a CMakeTarget -# ./scripts/build-release launchdarkly-cpp-client +# Call this script with a CMakeTarget and optional flags +# ./scripts/build-release.sh launchdarkly-cpp-client +# ./scripts/build-release.sh launchdarkly-cpp-client --with-curl set -e +# Parse arguments +TARGET="$1" +build_redis="OFF" +build_curl="OFF" # Special case: unlike the other targets, enabling redis support will pull in redis++ and hiredis dependencies at # configuration time. To ensure this only happens when asked, disable the support by default. -build_redis="OFF" -if [ "$1" == "launchdarkly-cpp-server-redis-source" ]; then +if [ "$TARGET" == "launchdarkly-cpp-server-redis-source" ]; then build_redis="ON" fi +# Check for --with-curl flag +for arg in "$@"; do + if [ "$arg" == "--with-curl" ]; then + build_curl="ON" + break + fi +done + +# Determine suffix for build directories +if [ "$build_curl" == "ON" ]; then + suffix="-curl" +else + suffix="" +fi + # Build a static release. -mkdir -p build-static && cd build-static +mkdir -p "build-static${suffix}" && cd "build-static${suffix}" mkdir -p release -cmake -G Ninja -D CMAKE_BUILD_TYPE=Release -D LD_BUILD_REDIS_SUPPORT="$build_redis" -D BUILD_TESTING=OFF -D CMAKE_INSTALL_PREFIX=./release .. +cmake -G Ninja -D CMAKE_BUILD_TYPE=Release -D LD_BUILD_REDIS_SUPPORT="$build_redis" -D LD_CURL_NETWORKING="$build_curl" -D BUILD_TESTING=OFF -D CMAKE_INSTALL_PREFIX=./release .. -cmake --build . --target "$1" +cmake --build . --target "$TARGET" cmake --install . cd .. # Build a dynamic release. -mkdir -p build-dynamic && cd build-dynamic +mkdir -p "build-dynamic${suffix}" && cd "build-dynamic${suffix}" mkdir -p release -cmake -G Ninja -D CMAKE_BUILD_TYPE=Release -D LD_BUILD_REDIS_SUPPORT="$build_redis" -D BUILD_TESTING=OFF -D LD_BUILD_SHARED_LIBS=ON -D CMAKE_INSTALL_PREFIX=./release .. +cmake -G Ninja -D CMAKE_BUILD_TYPE=Release -D LD_BUILD_REDIS_SUPPORT="$build_redis" -D LD_CURL_NETWORKING="$build_curl" -D BUILD_TESTING=OFF -D LD_BUILD_SHARED_LIBS=ON -D CMAKE_INSTALL_PREFIX=./release .. -cmake --build . --target "$1" +cmake --build . --target "$TARGET" cmake --install . cd .. From c8ed14ea2449a93db4b41b88e0974f4c1b1f6d67 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 13 Oct 2025 16:27:34 -0700 Subject: [PATCH 34/90] Revert example changes --- examples/hello-c-client/main.c | 3 --- examples/hello-cpp-client/CMakeLists.txt | 5 +---- examples/hello-cpp-client/main.cpp | 5 +---- examples/hello-cpp-server/main.cpp | 5 +---- 4 files changed, 3 insertions(+), 15 deletions(-) diff --git a/examples/hello-c-client/main.c b/examples/hello-c-client/main.c index 33d043637..83d9efd48 100644 --- a/examples/hello-c-client/main.c +++ b/examples/hello-c-client/main.c @@ -32,8 +32,6 @@ int main() { LDClientConfigBuilder config_builder = LDClientConfigBuilder_New(mobile_key); - LDClientConfigBuilder_HttpProperties_Proxy(config_builder, "socks5h://puser:ppass@localhost:1080"); - LDClientConfig config = NULL; LDStatus config_status = LDClientConfigBuilder_Build(config_builder, &config); @@ -61,7 +59,6 @@ int main() { } else { printf("SDK initialization didn't complete in %dms\n", INIT_TIMEOUT_MILLISECONDS); - LDClientSDK_Free(client); return 1; } diff --git a/examples/hello-cpp-client/CMakeLists.txt b/examples/hello-cpp-client/CMakeLists.txt index 4e47a86bd..c99ab3215 100644 --- a/examples/hello-cpp-client/CMakeLists.txt +++ b/examples/hello-cpp-client/CMakeLists.txt @@ -10,9 +10,6 @@ project( set(THREADS_PREFER_PTHREAD_FLAG ON) find_package(Threads REQUIRED) -find_package(CURL) add_executable(hello-cpp-client main.cpp) -target_link_libraries(hello-cpp-client PRIVATE launchdarkly::client Threads::Threads CURL::libcurl) - - +target_link_libraries(hello-cpp-client PRIVATE launchdarkly::client Threads::Threads) diff --git a/examples/hello-cpp-client/main.cpp b/examples/hello-cpp-client/main.cpp index b063dd04e..3ac7c99ea 100644 --- a/examples/hello-cpp-client/main.cpp +++ b/examples/hello-cpp-client/main.cpp @@ -29,10 +29,7 @@ int main() { "variable.\n" "The value of MOBILE_KEY in main.c takes priority over LD_MOBILE_KEY."); - auto builder = ConfigBuilder(mobile_key); - builder.HttpProperties().Proxy("socks5h://puser:ppass@localhost:1080"); - auto config = builder.Build(); - + auto config = ConfigBuilder(mobile_key).Build(); if (!config) { std::cout << "error: config is invalid: " << config.error() << '\n'; return 1; diff --git a/examples/hello-cpp-server/main.cpp b/examples/hello-cpp-server/main.cpp index e3a3399a3..3aaba377d 100644 --- a/examples/hello-cpp-server/main.cpp +++ b/examples/hello-cpp-server/main.cpp @@ -30,10 +30,7 @@ int main() { "variable.\n" "The value of SDK_KEY in main.c takes priority over LD_SDK_KEY."); - auto builder = ConfigBuilder(sdk_key); - builder.HttpProperties().Proxy("socks5h://puser:ppass@proxy:1080"); - auto config = builder.Build(); - + auto config = ConfigBuilder(sdk_key).Build(); if (!config) { std::cout << "error: config is invalid: " << config.error() << '\n'; return 1; From 33638383d2a80378a9572e34a85a229f2bbbddcd Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 13 Oct 2025 16:28:22 -0700 Subject: [PATCH 35/90] Revert SSE contract test changes. --- contract-tests/sse-contract-tests/README.md | 24 ------------------- .../sse-contract-tests/src/main.cpp | 11 +++------ 2 files changed, 3 insertions(+), 32 deletions(-) diff --git a/contract-tests/sse-contract-tests/README.md b/contract-tests/sse-contract-tests/README.md index 92e23465a..9a3e4367d 100644 --- a/contract-tests/sse-contract-tests/README.md +++ b/contract-tests/sse-contract-tests/README.md @@ -5,30 +5,6 @@ the other. This project implements the test service for the C++ EventSource client. -### Usage - -```bash -sse-tests [PORT] [--use-curl] -``` - -- `PORT`: Optional port number (defaults to 8123) -- `--use-curl`: Optional flag to use the CURL-based SSE client implementation instead of the default Boost.Beast/Foxy implementation - -Examples: -```bash -# Start on default port 8123 with Foxy client -./sse-tests - -# Start on port 9000 with Foxy client -./sse-tests 9000 - -# Start on default port with CURL client -./sse-tests --use-curl - -# Start on port 9000 with CURL client -./sse-tests 9000 --use-curl -``` - **session (session.hpp)** This provides a simple REST API for creating/destroying diff --git a/contract-tests/sse-contract-tests/src/main.cpp b/contract-tests/sse-contract-tests/src/main.cpp index a37bc0d7f..0a9881466 100644 --- a/contract-tests/sse-contract-tests/src/main.cpp +++ b/contract-tests/sse-contract-tests/src/main.cpp @@ -22,14 +22,9 @@ int main(int argc, char* argv[]) { std::string const default_port = "8123"; std::string port = default_port; - - // Parse command line arguments - for (int i = 1; i < argc; ++i) { - std::string arg = argv[i]; // NOLINT(cppcoreguidelines-pro-bounds-pointer-arithmetic) - if (i == 1 && arg.find("--") != 0) { - // First non-flag argument is the port - port = arg; - } + if (argc == 2) { + port = + argv[1]; // NOLINT(cppcoreguidelines-pro-bounds-pointer-arithmetic) } try { From 9e625dfe6f03aa6d85fc455ee0b6eb262402ccdc Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 13 Oct 2025 16:34:41 -0700 Subject: [PATCH 36/90] Correct CURL download URLs for windows. --- .github/actions/install-curl/action.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/actions/install-curl/action.yml b/.github/actions/install-curl/action.yml index 96c1de15a..9a07ae58f 100644 --- a/.github/actions/install-curl/action.yml +++ b/.github/actions/install-curl/action.yml @@ -34,14 +34,14 @@ runs: id: windows-action shell: bash run: | - # Download CURL 8.16.0 with OpenSSL for Windows (MSVC build) - curl -L https://curl.se/windows/dl-8.16.0/curl-8.16.0-win64-msvc.zip -o curl.zip + # Download CURL 8.16.0 with OpenSSL for Windows (MinGW build, compatible with MSVC) + curl -L https://curl.se/windows/dl-8.16.0_6/curl-8.16.0_6-win64-mingw.zip -o curl.zip # Extract to C:\curl-install 7z x curl.zip -oC:\curl-install - # The archive extracts to a subdirectory named curl-8.16.0-win64-msvc - echo "CURL_ROOT=C:\\curl-install\\curl-8.16.0-win64-msvc" >> $GITHUB_OUTPUT + # The archive extracts to a subdirectory named curl-8.16.0_6-win64-mingw + echo "CURL_ROOT=C:\\curl-install\\curl-8.16.0_6-win64-mingw" >> $GITHUB_OUTPUT - name: Determine root id: determine-root From ef61eeb8c13ed321e428662e7e501395eab99811 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 13 Oct 2025 16:53:21 -0700 Subject: [PATCH 37/90] Use powershell instead of bash to install curl. --- .github/actions/install-curl/action.yml | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/.github/actions/install-curl/action.yml b/.github/actions/install-curl/action.yml index 9a07ae58f..8420a1135 100644 --- a/.github/actions/install-curl/action.yml +++ b/.github/actions/install-curl/action.yml @@ -32,16 +32,29 @@ runs: - name: Install CURL for Windows if: runner.os == 'Windows' id: windows-action - shell: bash + shell: pwsh run: | # Download CURL 8.16.0 with OpenSSL for Windows (MinGW build, compatible with MSVC) - curl -L https://curl.se/windows/dl-8.16.0_6/curl-8.16.0_6-win64-mingw.zip -o curl.zip + $url = "https://curl.se/windows/dl-8.16.0_6/curl-8.16.0_6-win64-mingw.zip" + $output = "curl.zip" + + Write-Host "Downloading CURL from $url" + Invoke-WebRequest -Uri $url -OutFile $output -MaximumRetryCount 3 -RetryIntervalSec 5 + + # Verify the download + $fileInfo = Get-Item $output + Write-Host "Downloaded file size: $($fileInfo.Length) bytes" # Extract to C:\curl-install - 7z x curl.zip -oC:\curl-install + Write-Host "Extracting CURL archive..." + Expand-Archive -Path $output -DestinationPath "C:\curl-install" -Force + + # Verify extraction + Write-Host "Extracted contents:" + Get-ChildItem -Path "C:\curl-install" -Recurse -Depth 1 # The archive extracts to a subdirectory named curl-8.16.0_6-win64-mingw - echo "CURL_ROOT=C:\\curl-install\\curl-8.16.0_6-win64-mingw" >> $GITHUB_OUTPUT + echo "CURL_ROOT=C:\curl-install\curl-8.16.0_6-win64-mingw" >> $env:GITHUB_OUTPUT - name: Determine root id: determine-root From 0e57e52392dd04aae48a924d55a5a995bda4feea Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 14 Oct 2025 08:35:55 -0700 Subject: [PATCH 38/90] Forward CMAKE_EXTRA_ARGS. --- scripts/configure-cmake-integration-tests.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/configure-cmake-integration-tests.sh b/scripts/configure-cmake-integration-tests.sh index 2336c9b4e..9ce79b446 100755 --- a/scripts/configure-cmake-integration-tests.sh +++ b/scripts/configure-cmake-integration-tests.sh @@ -20,4 +20,5 @@ cmake -G Ninja -D CMAKE_COMPILE_WARNING_AS_ERROR=TRUE \ -D OPENSSL_ROOT_DIR="$OPENSSL_ROOT_DIR" \ -D LD_TESTING_SANITIZERS=OFF \ -D CMAKE_INSTALL_PREFIX="$CMAKE_INSTALL_PREFIX" \ - -D LD_BUILD_EXAMPLES=OFF .. + -D LD_BUILD_EXAMPLES=OFF \ + $CMAKE_EXTRA_ARGS .. From 5ab043e1bdc63e549bdb7e0922e7bd1131f5fcc1 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 14 Oct 2025 08:39:15 -0700 Subject: [PATCH 39/90] Extra actions in workflow --- .github/actions/cmake-test/action.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/actions/cmake-test/action.yml b/.github/actions/cmake-test/action.yml index 1732d2762..860652d60 100644 --- a/.github/actions/cmake-test/action.yml +++ b/.github/actions/cmake-test/action.yml @@ -11,6 +11,10 @@ inputs: toolset: description: 'Boost toolset' required: false + cmake_extra_args: + description: 'Extra arguments to pass to CMake' + required: false + default: '' runs: using: composite @@ -36,6 +40,7 @@ runs: BOOST_ROOT: ${{ steps.install-boost.outputs.BOOST_ROOT }} OPENSSL_ROOT_DIR: ${{ steps.install-openssl.outputs.OPENSSL_ROOT_DIR }} CMAKE_INSTALL_PREFIX: ../LAUNCHDARKLY_INSTALL + CMAKE_EXTRA_ARGS: ${{ inputs.cmake_extra_args }} - name: Build the SDK shell: bash run: | From eaca3a02eafbbeb690430d1d117c6c145a375ce9 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 14 Oct 2025 08:53:25 -0700 Subject: [PATCH 40/90] More CMAKE args plumbing. --- .github/workflows/cmake.yml | 14 ++++++++++---- scripts/configure-cmake-integration-tests.sh | 3 ++- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/.github/workflows/cmake.yml b/.github/workflows/cmake.yml index 4625ad998..9bb39e8b7 100644 --- a/.github/workflows/cmake.yml +++ b/.github/workflows/cmake.yml @@ -26,9 +26,12 @@ jobs: runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 - - name: Install CURL - run: sudo apt-get update && sudo apt-get install -y libcurl4-openssl-dev + - uses: ./.github/actions/install-curl + id: install-curl - uses: ./.github/actions/cmake-test + env: + CURL_ROOT: ${{ steps.install-curl.outputs.CURL_ROOT }} + CMAKE_PREFIX_PATH: ${{ steps.install-curl.outputs.CURL_ROOT }} with: platform_version: '22.04' cmake_extra_args: '-DLD_CURL_NETWORKING=ON' @@ -45,9 +48,12 @@ jobs: runs-on: macos-13 steps: - uses: actions/checkout@v4 - - name: Install CURL - run: brew install curl + - uses: ./.github/actions/install-curl + id: install-curl - uses: ./.github/actions/cmake-test + env: + CURL_ROOT: ${{ steps.install-curl.outputs.CURL_ROOT }} + CMAKE_PREFIX_PATH: ${{ steps.install-curl.outputs.CURL_ROOT }} with: platform_version: '12' cmake_extra_args: '-DLD_CURL_NETWORKING=ON' diff --git a/scripts/configure-cmake-integration-tests.sh b/scripts/configure-cmake-integration-tests.sh index 9ce79b446..2b732bf78 100755 --- a/scripts/configure-cmake-integration-tests.sh +++ b/scripts/configure-cmake-integration-tests.sh @@ -21,4 +21,5 @@ cmake -G Ninja -D CMAKE_COMPILE_WARNING_AS_ERROR=TRUE \ -D LD_TESTING_SANITIZERS=OFF \ -D CMAKE_INSTALL_PREFIX="$CMAKE_INSTALL_PREFIX" \ -D LD_BUILD_EXAMPLES=OFF \ - $CMAKE_EXTRA_ARGS .. + ${CMAKE_EXTRA_ARGS} \ + .. From 9fffe56de9c07af3fcc318b4437ba250685b9166 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 14 Oct 2025 09:18:35 -0700 Subject: [PATCH 41/90] Update the launchdarkly cmake config to handle optional curl dependency. --- cmake/launchdarklyConfig.cmake | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cmake/launchdarklyConfig.cmake b/cmake/launchdarklyConfig.cmake index 87b0afc76..3e4122a4b 100644 --- a/cmake/launchdarklyConfig.cmake +++ b/cmake/launchdarklyConfig.cmake @@ -20,4 +20,8 @@ find_dependency(OpenSSL) find_dependency(tl-expected) find_dependency(certify) +if (LD_CURL_NETWORKING) + find_dependency(CURL) +endif() + include(${CMAKE_CURRENT_LIST_DIR}/launchdarklyTargets.cmake) From a42b4a3f4a5121302ad6e19cc9b4d7e71abc482d Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 14 Oct 2025 09:25:37 -0700 Subject: [PATCH 42/90] Add configuration output to the cmake tests. --- scripts/configure-cmake-integration-tests.sh | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/scripts/configure-cmake-integration-tests.sh b/scripts/configure-cmake-integration-tests.sh index 2b732bf78..6384ab0d3 100755 --- a/scripts/configure-cmake-integration-tests.sh +++ b/scripts/configure-cmake-integration-tests.sh @@ -10,7 +10,14 @@ cd build # script ends. trap cleanup EXIT - +echo "=== CMake Configuration Parameters ===" +echo "BOOST_ROOT: $BOOST_ROOT" +echo "OPENSSL_ROOT_DIR: $OPENSSL_ROOT_DIR" +echo "CMAKE_INSTALL_PREFIX: $CMAKE_INSTALL_PREFIX" +echo "CMAKE_EXTRA_ARGS: $CMAKE_EXTRA_ARGS" +echo "CURL_ROOT: $CURL_ROOT" +echo "CMAKE_PREFIX_PATH: $CMAKE_PREFIX_PATH" +echo "=======================================" cmake -G Ninja -D CMAKE_COMPILE_WARNING_AS_ERROR=TRUE \ -D BUILD_TESTING=ON \ From 1a7675283200c6a5da1da1042a0118bd830216e6 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 14 Oct 2025 09:50:55 -0700 Subject: [PATCH 43/90] Optionally find CURL in cmake config. --- cmake/launchdarklyConfig.cmake | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/cmake/launchdarklyConfig.cmake b/cmake/launchdarklyConfig.cmake index 3e4122a4b..aa34498dc 100644 --- a/cmake/launchdarklyConfig.cmake +++ b/cmake/launchdarklyConfig.cmake @@ -20,8 +20,9 @@ find_dependency(OpenSSL) find_dependency(tl-expected) find_dependency(certify) -if (LD_CURL_NETWORKING) - find_dependency(CURL) -endif() +# If the SDK was built with CURL networking support, CURL::libcurl will be +# referenced in the exported targets, so we need to find it. +# We use find_package directly with QUIET so it doesn't fail if CURL isn't needed. +find_package(CURL QUIET) include(${CMAKE_CURRENT_LIST_DIR}/launchdarklyTargets.cmake) From e42aa31003b53c941a41a6bdb92fbb69d6e76de8 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 14 Oct 2025 09:53:32 -0700 Subject: [PATCH 44/90] Update cmake-test readme for CURL tests. --- cmake-tests/README.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/cmake-tests/README.md b/cmake-tests/README.md index 4132ef180..37faebfaf 100644 --- a/cmake-tests/README.md +++ b/cmake-tests/README.md @@ -55,6 +55,38 @@ Additionally, certain variables must be forwarded to each test project CMake con |--------------------|---------------------------------------------------------------------------------------------------------| | `Boost_DIR` | Path to boost CMAKE configuration. Example: 'C:\local\boost_1_87_0\lib64-msvc-14.3\cmake\Boost-1.87.0' | | `OPENSSL_ROOT_DIR` | Path to OpenSSL. | +| `CMAKE_EXTRA_ARGS` | Additional CMake arguments to pass to the SDK configuration. Example: '-DLD_CURL_NETWORKING=ON' | +| `CURL_ROOT` | Path to CURL installation (required when building with `-DLD_CURL_NETWORKING=ON`). | +| `CMAKE_PREFIX_PATH`| Additional paths for CMake to search for packages (often set to `CURL_ROOT` for CURL builds). | + +## Testing with CURL Networking + +The SDK can be built with CURL networking support instead of the default Boost.Beast/Foxy implementation +by passing `-DLD_CURL_NETWORKING=ON` to CMake. To test the CMake integration with CURL enabled: + +1. Set the environment variables before running the configuration script: +```bash +export CMAKE_EXTRA_ARGS="-DLD_CURL_NETWORKING=ON" +export CURL_ROOT=/path/to/curl # or /usr on most Linux systems +export CMAKE_PREFIX_PATH=$CURL_ROOT +./scripts/configure-cmake-integration-tests.sh +``` + +2. Build and install the SDK: +```bash +cmake --build build +cmake --install build +``` + +3. Run the integration tests: +```bash +cd build/cmake-tests +ctest --output-on-failure +``` + +The `launchdarklyConfig.cmake` file will automatically detect and find CURL when the SDK was built +with CURL support, using `find_package(CURL QUIET)`. This allows downstream projects using +`find_package(launchdarkly)` to work seamlessly whether the SDK was built with CURL or Boost.Beast. ## Tests From d1cfb47b65fb8fe7ebb4e3bc16bb1149c963aa5d Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 14 Oct 2025 10:14:28 -0700 Subject: [PATCH 45/90] Capture num_events explicitly for MSVC --- libs/server-sent-events/tests/curl_client_test.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/server-sent-events/tests/curl_client_test.cpp b/libs/server-sent-events/tests/curl_client_test.cpp index e2a6dcd0c..425258b7c 100644 --- a/libs/server-sent-events/tests/curl_client_test.cpp +++ b/libs/server-sent-events/tests/curl_client_test.cpp @@ -794,7 +794,7 @@ TEST(CurlClientTest, HandlesRapidEvents) { MockSSEServer server; const int num_events = 100; - auto port = server.start([](auto const&, auto send_response, auto send_sse_event, auto close) { + auto port = server.start([num_events](auto const&, auto send_response, auto send_sse_event, auto close) { http::response res{http::status::ok, 11}; res.set(http::field::content_type, "text/event-stream"); res.chunked(true); From 816064eaff683b2b1aa57e24d69fd663dd9fab06 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 14 Oct 2025 10:33:08 -0700 Subject: [PATCH 46/90] Build curl from source. --- .github/actions/install-curl/action.yml | 42 +++++++++++++++---------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/.github/actions/install-curl/action.yml b/.github/actions/install-curl/action.yml index 8420a1135..8383240ce 100644 --- a/.github/actions/install-curl/action.yml +++ b/.github/actions/install-curl/action.yml @@ -28,33 +28,43 @@ runs: brew install curl echo "CURL_ROOT=$(brew --prefix curl)" >> $GITHUB_OUTPUT - # Windows: Download pre-built binaries from curl.se + # Windows: Build CURL from source with MSVC - name: Install CURL for Windows if: runner.os == 'Windows' id: windows-action shell: pwsh run: | - # Download CURL 8.16.0 with OpenSSL for Windows (MinGW build, compatible with MSVC) - $url = "https://curl.se/windows/dl-8.16.0_6/curl-8.16.0_6-win64-mingw.zip" - $output = "curl.zip" + Write-Host "Building CURL from source with MSVC..." - Write-Host "Downloading CURL from $url" + # Download CURL source + $version = "8.11.1" + $url = "https://curl.se/download/curl-$version.tar.gz" + $output = "curl-$version.tar.gz" + + Write-Host "Downloading CURL $version source from $url" Invoke-WebRequest -Uri $url -OutFile $output -MaximumRetryCount 3 -RetryIntervalSec 5 - # Verify the download - $fileInfo = Get-Item $output - Write-Host "Downloaded file size: $($fileInfo.Length) bytes" + # Extract + Write-Host "Extracting..." + tar -xzf $output + + # Build with CMake (MSVC) + cd "curl-$version" - # Extract to C:\curl-install - Write-Host "Extracting CURL archive..." - Expand-Archive -Path $output -DestinationPath "C:\curl-install" -Force + Write-Host "Configuring CURL with CMake..." + cmake -G "Visual Studio 17 2022" -A x64 ` + -DCMAKE_INSTALL_PREFIX="C:\curl-install" ` + -DBUILD_SHARED_LIBS=OFF ` + -DCURL_USE_SCHANNEL=ON ` + -DCURL_DISABLE_TESTS=ON ` + -DBUILD_TESTING=OFF ` + -S . -B build - # Verify extraction - Write-Host "Extracted contents:" - Get-ChildItem -Path "C:\curl-install" -Recurse -Depth 1 + Write-Host "Building CURL..." + cmake --build build --config Release --target install - # The archive extracts to a subdirectory named curl-8.16.0_6-win64-mingw - echo "CURL_ROOT=C:\curl-install\curl-8.16.0_6-win64-mingw" >> $env:GITHUB_OUTPUT + Write-Host "CURL installed to C:\curl-install" + echo "CURL_ROOT=C:\curl-install" >> $env:GITHUB_OUTPUT - name: Determine root id: determine-root From ae52c2cc75d3e28ed6860e94d2d56451d2791fbc Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 14 Oct 2025 10:49:04 -0700 Subject: [PATCH 47/90] Refactor windows CURL building and document. --- .github/actions/install-curl/action.yml | 36 +++-------- README.md | 43 +++++++++++++ scripts/build-curl-windows.ps1 | 83 +++++++++++++++++++++++++ 3 files changed, 133 insertions(+), 29 deletions(-) create mode 100644 scripts/build-curl-windows.ps1 diff --git a/.github/actions/install-curl/action.yml b/.github/actions/install-curl/action.yml index 8383240ce..c81da8df9 100644 --- a/.github/actions/install-curl/action.yml +++ b/.github/actions/install-curl/action.yml @@ -28,42 +28,20 @@ runs: brew install curl echo "CURL_ROOT=$(brew --prefix curl)" >> $GITHUB_OUTPUT - # Windows: Build CURL from source with MSVC + # Windows: Build CURL from source with MSVC using helper script - name: Install CURL for Windows if: runner.os == 'Windows' id: windows-action shell: pwsh run: | - Write-Host "Building CURL from source with MSVC..." + # Use the build script from the repository + & "${{ github.workspace }}\scripts\build-curl-windows.ps1" -Version "8.11.1" -InstallPrefix "C:\curl-install" - # Download CURL source - $version = "8.11.1" - $url = "https://curl.se/download/curl-$version.tar.gz" - $output = "curl-$version.tar.gz" + if ($LASTEXITCODE -ne 0) { + Write-Error "CURL build failed" + exit 1 + } - Write-Host "Downloading CURL $version source from $url" - Invoke-WebRequest -Uri $url -OutFile $output -MaximumRetryCount 3 -RetryIntervalSec 5 - - # Extract - Write-Host "Extracting..." - tar -xzf $output - - # Build with CMake (MSVC) - cd "curl-$version" - - Write-Host "Configuring CURL with CMake..." - cmake -G "Visual Studio 17 2022" -A x64 ` - -DCMAKE_INSTALL_PREFIX="C:\curl-install" ` - -DBUILD_SHARED_LIBS=OFF ` - -DCURL_USE_SCHANNEL=ON ` - -DCURL_DISABLE_TESTS=ON ` - -DBUILD_TESTING=OFF ` - -S . -B build - - Write-Host "Building CURL..." - cmake --build build --config Release --target install - - Write-Host "CURL installed to C:\curl-install" echo "CURL_ROOT=C:\curl-install" >> $env:GITHUB_OUTPUT - name: Determine root diff --git a/README.md b/README.md index 4939963d3..15dc09893 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,49 @@ cmake -B build -S . -G"Unix Makefiles" \ The example uses `make`, but you might instead use [Ninja](https://ninja-build.org/), MSVC, [etc.](https://cmake.org/cmake/help/latest/manual/cmake-generators.7.html) +### Building with CURL Networking + +By default, the SDK uses Boost.Beast/Foxy for HTTP networking. To use CURL instead, enable the `LD_CURL_NETWORKING` option: + +```bash +cmake -B build -S . -DLD_CURL_NETWORKING=ON +``` + +#### CURL Requirements by Platform + +**Linux/macOS:** +Install CURL development libraries via your package manager: +```bash +# Ubuntu/Debian +sudo apt-get install libcurl4-openssl-dev + +# macOS +brew install curl +``` + +**Windows (MSVC):** +CURL must be built from source using MSVC to ensure ABI compatibility. A helper script is provided: + +```powershell +.\scripts\build-curl-windows.ps1 -Version "8.11.1" -InstallPrefix "C:\curl-install" +``` + +Then configure the SDK with: +```powershell +cmake -B build -S . -DLD_CURL_NETWORKING=ON ` + -DCURL_ROOT="C:\curl-install" ` + -DCMAKE_PREFIX_PATH="C:\curl-install" +``` + +The `build-curl-windows.ps1` script: +- Downloads CURL source from curl.se +- Builds static libraries with MSVC using CMake +- Uses Windows Schannel for SSL (no OpenSSL dependency) +- Installs to the specified prefix directory + +> [!NOTE] +> Pre-built CURL binaries from curl.se (MinGW builds) are **not** compatible with MSVC and will cause linker errors. + ## Incorporating the SDK via `add_subdirectory` The SDK can be incorporated into an existing application using CMake via `add_subdirectory.`. diff --git a/scripts/build-curl-windows.ps1 b/scripts/build-curl-windows.ps1 new file mode 100644 index 000000000..6e08c663e --- /dev/null +++ b/scripts/build-curl-windows.ps1 @@ -0,0 +1,83 @@ +# Build CURL from source for Windows using MSVC +# This script downloads and builds CURL with static libraries compatible with MSVC +# +# Usage: +# .\build-curl-windows.ps1 [-Version ] [-InstallPrefix ] +# +# Parameters: +# -Version: CURL version to download (default: 8.11.1) +# -InstallPrefix: Installation directory (default: C:\curl-install) + +param( + [string]$Version = "8.11.1", + [string]$InstallPrefix = "C:\curl-install" +) + +Write-Host "Building CURL $Version from source with MSVC..." + +# Download CURL source +$url = "https://curl.se/download/curl-$Version.tar.gz" +$output = "curl-$Version.tar.gz" + +Write-Host "Downloading CURL $Version source from $url" +try { + Invoke-WebRequest -Uri $url -OutFile $output -MaximumRetryCount 3 -RetryIntervalSec 5 +} catch { + Write-Error "Failed to download CURL: $_" + exit 1 +} + +# Extract +Write-Host "Extracting..." +try { + tar -xzf $output +} catch { + Write-Error "Failed to extract CURL: $_" + exit 1 +} + +# Build with CMake (MSVC) +Set-Location "curl-$Version" + +Write-Host "Configuring CURL with CMake..." +Write-Host "Install prefix: $InstallPrefix" + +try { + cmake -G "Visual Studio 17 2022" -A x64 ` + -DCMAKE_INSTALL_PREFIX="$InstallPrefix" ` + -DBUILD_SHARED_LIBS=OFF ` + -DCURL_USE_SCHANNEL=ON ` + -DCURL_DISABLE_TESTS=ON ` + -DBUILD_TESTING=OFF ` + -S . -B build + + if ($LASTEXITCODE -ne 0) { + throw "CMake configuration failed" + } +} catch { + Write-Error "Failed to configure CURL: $_" + Set-Location .. + exit 1 +} + +Write-Host "Building CURL..." +try { + cmake --build build --config Release --target install + + if ($LASTEXITCODE -ne 0) { + throw "CMake build failed" + } +} catch { + Write-Error "Failed to build CURL: $_" + Set-Location .. + exit 1 +} + +Set-Location .. + +Write-Host "SUCCESS: CURL installed to $InstallPrefix" +Write-Host "" +Write-Host "To use this CURL installation with the LaunchDarkly C++ SDK:" +Write-Host " Set CURL_ROOT=$InstallPrefix" +Write-Host " Set CMAKE_PREFIX_PATH=$InstallPrefix" +Write-Host " Configure with: -DLD_CURL_NETWORKING=ON" From f7a24905ff6405ee37a61c785ace6668c7969f72 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 14 Oct 2025 12:47:39 -0700 Subject: [PATCH 48/90] Extend error handling. --- libs/internal/src/network/curl_requester.cpp | 60 +++++++++------ libs/server-sent-events/src/curl_client.cpp | 81 +++++++++++++------- libs/server-sent-events/src/curl_client.hpp | 2 +- 3 files changed, 92 insertions(+), 51 deletions(-) diff --git a/libs/internal/src/network/curl_requester.cpp b/libs/internal/src/network/curl_requester.cpp index 7120a222f..9b8b94858 100644 --- a/libs/internal/src/network/curl_requester.cpp +++ b/libs/internal/src/network/curl_requester.cpp @@ -103,6 +103,22 @@ void CurlRequester::PerformRequestStatic(net::any_io_executor ctx, TlsOptions co // Use a unique_ptr to manage the cleanup of our curl instance. std::unique_ptr curl_guard(curl, curl_easy_cleanup); + // Helper macro to check curl_easy_setopt return values + // Note: This macro captures ctx and cb by reference for error reporting + #define CURL_SETOPT_CHECK(handle, option, parameter) \ + do { \ + CURLcode code = curl_easy_setopt(handle, option, parameter); \ + if (code != CURLE_OK) { \ + std::string error_message = kErrorCurlPrefix; \ + error_message += "curl_easy_setopt failed for " #option ": "; \ + error_message += curl_easy_strerror(code); \ + boost::asio::post(ctx, [cb = std::move(cb), error_message = std::move(error_message)]() { \ + cb(HttpResult(error_message)); \ + }); \ + return; \ + } \ + } while(0) + std::string response_body; HttpResult::HeadersType response_headers; @@ -110,7 +126,7 @@ void CurlRequester::PerformRequestStatic(net::any_io_executor ctx, TlsOptions co std::string url = request.Url(); // Set URL - curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); + CURL_SETOPT_CHECK(curl, CURLOPT_URL, url.c_str()); // Set HTTP method if (request.Method() == HttpMethod::kPost) { @@ -118,20 +134,20 @@ void CurlRequester::PerformRequestStatic(net::any_io_executor ctx, TlsOptions co // Passing 1 enables this flag. // This will also set a content type, but the headers for the request // should override that with the correct value. - curl_easy_setopt(curl, CURLOPT_POST, 1L); + CURL_SETOPT_CHECK(curl, CURLOPT_POST, 1L); } else if (request.Method() == HttpMethod::kPut) { - curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, kHttpMethodPut); + CURL_SETOPT_CHECK(curl, CURLOPT_CUSTOMREQUEST, kHttpMethodPut); } else if (request.Method() == HttpMethod::kReport) { - curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, kHttpMethodReport); + CURL_SETOPT_CHECK(curl, CURLOPT_CUSTOMREQUEST, kHttpMethodReport); } else if (request.Method() == HttpMethod::kGet) { - curl_easy_setopt(curl, CURLOPT_HTTPGET, 1L); + CURL_SETOPT_CHECK(curl, CURLOPT_HTTPGET, 1L); } // Set request body if present if (request.Body().has_value()) { const std::string& body = request.Body().value(); - curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body.c_str()); - curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, body.size()); + CURL_SETOPT_CHECK(curl, CURLOPT_POSTFIELDS, body.c_str()); + CURL_SETOPT_CHECK(curl, CURLOPT_POSTFIELDSIZE, body.size()); } // Set headers @@ -156,31 +172,31 @@ void CurlRequester::PerformRequestStatic(net::any_io_executor ctx, TlsOptions co headers = appendResult; } if (headers) { - curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + CURL_SETOPT_CHECK(curl, CURLOPT_HTTPHEADER, headers); } // Set timeouts with millisecond precision const long connect_timeout_ms = request.Properties().ConnectTimeout().count(); const long response_timeout_ms = request.Properties().ResponseTimeout().count(); - curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT_MS, connect_timeout_ms > 0 ? connect_timeout_ms : 30000L); - curl_easy_setopt(curl, CURLOPT_TIMEOUT_MS, response_timeout_ms > 0 ? response_timeout_ms : 60000L); + CURL_SETOPT_CHECK(curl, CURLOPT_CONNECTTIMEOUT_MS, connect_timeout_ms > 0 ? connect_timeout_ms : 30000L); + CURL_SETOPT_CHECK(curl, CURLOPT_TIMEOUT_MS, response_timeout_ms > 0 ? response_timeout_ms : 60000L); // Set TLS options using VerifyMode = config::shared::built::TlsOptions::VerifyMode; if (tls_options.PeerVerifyMode() == VerifyMode::kVerifyNone) { - curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0L); - curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 0L); + CURL_SETOPT_CHECK(curl, CURLOPT_SSL_VERIFYPEER, 0L); + CURL_SETOPT_CHECK(curl, CURLOPT_SSL_VERIFYHOST, 0L); } else { - curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L); + CURL_SETOPT_CHECK(curl, CURLOPT_SSL_VERIFYPEER, 1L); // 1 or 2 seem to basically be the same, but the documentation says to // use 2, and that it would default to 2. // https://curl.se/libcurl/c/CURLOPT_SSL_VERIFYHOST.html - curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L); + CURL_SETOPT_CHECK(curl, CURLOPT_SSL_VERIFYHOST, 2L); // Set custom CA file if provided if (tls_options.CustomCAFile().has_value()) { - curl_easy_setopt(curl, CURLOPT_CAINFO, tls_options.CustomCAFile()->c_str()); + CURL_SETOPT_CHECK(curl, CURLOPT_CAINFO, tls_options.CustomCAFile()->c_str()); } } @@ -189,19 +205,19 @@ void CurlRequester::PerformRequestStatic(net::any_io_executor ctx, TlsOptions co // Empty string explicitly disables proxy (overrides environment variables). auto const& proxy_url = request.Properties().Proxy().Url(); if (proxy_url.has_value()) { - curl_easy_setopt(curl, CURLOPT_PROXY, proxy_url->c_str()); + CURL_SETOPT_CHECK(curl, CURLOPT_PROXY, proxy_url->c_str()); } // If proxy URL is std::nullopt, CURL will use environment variables (default behavior) // Set callbacks - curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, WriteCallback); - curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response_body); - curl_easy_setopt(curl, CURLOPT_HEADERFUNCTION, HeaderCallback); - curl_easy_setopt(curl, CURLOPT_HEADERDATA, &response_headers); + CURL_SETOPT_CHECK(curl, CURLOPT_WRITEFUNCTION, WriteCallback); + CURL_SETOPT_CHECK(curl, CURLOPT_WRITEDATA, &response_body); + CURL_SETOPT_CHECK(curl, CURLOPT_HEADERFUNCTION, HeaderCallback); + CURL_SETOPT_CHECK(curl, CURLOPT_HEADERDATA, &response_headers); // Follow redirects - curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); - curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 20L); + CURL_SETOPT_CHECK(curl, CURLOPT_FOLLOWLOCATION, 1L); + CURL_SETOPT_CHECK(curl, CURLOPT_MAXREDIRS, 20L); // Perform the request CURLcode res = curl_easy_perform(curl); diff --git a/libs/server-sent-events/src/curl_client.cpp b/libs/server-sent-events/src/curl_client.cpp index cb7d2e003..816785cbc 100644 --- a/libs/server-sent-events/src/curl_client.cpp +++ b/libs/server-sent-events/src/curl_client.cpp @@ -161,30 +161,42 @@ std::string CurlClient::build_url() const { return url; } -struct curl_slist* CurlClient::setup_curl_options(CURL* curl) { +bool CurlClient::setup_curl_options(CURL* curl, struct curl_slist** out_headers) { + // Helper macro to check curl_easy_setopt return values + // Returns false on error to signal setup failure + #define CURL_SETOPT_CHECK(handle, option, parameter) \ + do { \ + CURLcode code = curl_easy_setopt(handle, option, parameter); \ + if (code != CURLE_OK) { \ + log_message("curl_easy_setopt failed for " #option ": " + \ + std::string(curl_easy_strerror(code))); \ + return false; \ + } \ + } while(0) + // Set URL - curl_easy_setopt(curl, CURLOPT_URL, build_url().c_str()); + CURL_SETOPT_CHECK(curl, CURLOPT_URL, build_url().c_str()); // Set HTTP method switch (req_.method()) { case http::verb::get: - curl_easy_setopt(curl, CURLOPT_HTTPGET, 1L); + CURL_SETOPT_CHECK(curl, CURLOPT_HTTPGET, 1L); break; case http::verb::post: - curl_easy_setopt(curl, CURLOPT_POST, 1L); + CURL_SETOPT_CHECK(curl, CURLOPT_POST, 1L); break; case http::verb::report: - curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "REPORT"); + CURL_SETOPT_CHECK(curl, CURLOPT_CUSTOMREQUEST, "REPORT"); break; default: - curl_easy_setopt(curl, CURLOPT_HTTPGET, 1L); + CURL_SETOPT_CHECK(curl, CURLOPT_HTTPGET, 1L); break; } // Set request body if present if (!req_.body().empty()) { - curl_easy_setopt(curl, CURLOPT_POSTFIELDS, req_.body().c_str()); - curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, req_.body().size()); + CURL_SETOPT_CHECK(curl, CURLOPT_POSTFIELDS, req_.body().c_str()); + CURL_SETOPT_CHECK(curl, CURLOPT_POSTFIELDSIZE, req_.body().size()); } // Set headers @@ -202,12 +214,12 @@ struct curl_slist* CurlClient::setup_curl_options(CURL* curl) { } if (headers) { - curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + CURL_SETOPT_CHECK(curl, CURLOPT_HTTPHEADER, headers); } // Set timeouts with millisecond precision if (connect_timeout_) { - curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT_MS, connect_timeout_->count()); + CURL_SETOPT_CHECK(curl, CURLOPT_CONNECTTIMEOUT_MS, connect_timeout_->count()); } // For read timeout, use progress callback @@ -215,21 +227,21 @@ struct curl_slist* CurlClient::setup_curl_options(CURL* curl) { effective_read_timeout_ = read_timeout_; last_progress_time_ = std::chrono::steady_clock::now(); last_download_amount_ = 0; - curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, ProgressCallback); - curl_easy_setopt(curl, CURLOPT_XFERINFODATA, this); - curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L); + CURL_SETOPT_CHECK(curl, CURLOPT_XFERINFOFUNCTION, ProgressCallback); + CURL_SETOPT_CHECK(curl, CURLOPT_XFERINFODATA, this); + CURL_SETOPT_CHECK(curl, CURLOPT_NOPROGRESS, 0L); } // Set TLS options if (skip_verify_peer_) { - curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0L); - curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 0L); + CURL_SETOPT_CHECK(curl, CURLOPT_SSL_VERIFYPEER, 0L); + CURL_SETOPT_CHECK(curl, CURLOPT_SSL_VERIFYHOST, 0L); } else { - curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L); - curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L); + CURL_SETOPT_CHECK(curl, CURLOPT_SSL_VERIFYPEER, 1L); + CURL_SETOPT_CHECK(curl, CURLOPT_SSL_VERIFYHOST, 2L); if (custom_ca_file_) { - curl_easy_setopt(curl, CURLOPT_CAINFO, custom_ca_file_->c_str()); + CURL_SETOPT_CHECK(curl, CURLOPT_CAINFO, custom_ca_file_->c_str()); } } @@ -237,22 +249,25 @@ struct curl_slist* CurlClient::setup_curl_options(CURL* curl) { // When proxy_url_ is set, it takes precedence over environment variables. // Empty string explicitly disables proxy (overrides environment variables). if (proxy_url_) { - curl_easy_setopt(curl, CURLOPT_PROXY, proxy_url_->c_str()); + CURL_SETOPT_CHECK(curl, CURLOPT_PROXY, proxy_url_->c_str()); } // If proxy_url_ is std::nullopt, CURL will use environment variables (default behavior) // Set callbacks - curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, WriteCallback); - curl_easy_setopt(curl, CURLOPT_WRITEDATA, this); - curl_easy_setopt(curl, CURLOPT_HEADERFUNCTION, HeaderCallback); - curl_easy_setopt(curl, CURLOPT_HEADERDATA, this); - curl_easy_setopt(curl, CURLOPT_OPENSOCKETFUNCTION, OpenSocketCallback); - curl_easy_setopt(curl, CURLOPT_OPENSOCKETDATA, this); + CURL_SETOPT_CHECK(curl, CURLOPT_WRITEFUNCTION, WriteCallback); + CURL_SETOPT_CHECK(curl, CURLOPT_WRITEDATA, this); + CURL_SETOPT_CHECK(curl, CURLOPT_HEADERFUNCTION, HeaderCallback); + CURL_SETOPT_CHECK(curl, CURLOPT_HEADERDATA, this); + CURL_SETOPT_CHECK(curl, CURLOPT_OPENSOCKETFUNCTION, OpenSocketCallback); + CURL_SETOPT_CHECK(curl, CURLOPT_OPENSOCKETDATA, this); // Don't follow redirects automatically - we'll handle them ourselves - curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 0L); + CURL_SETOPT_CHECK(curl, CURLOPT_FOLLOWLOCATION, 0L); + + #undef CURL_SETOPT_CHECK - return headers; + *out_headers = headers; + return true; } int CurlClient::ProgressCallback(void* clientp, curl_off_t dltotal, curl_off_t dlnow, @@ -530,7 +545,17 @@ void CurlClient::perform_request() { return; } - struct curl_slist* headers = setup_curl_options(curl); + struct curl_slist* headers = nullptr; + if (!setup_curl_options(curl, &headers)) { + // setup_curl_options returned false, indicating an error (it already logged the error) + curl_easy_cleanup(curl); + if (!shutting_down_) { + boost::asio::post(backoff_timer_.get_executor(), [self = shared_from_this()]() { + self->async_backoff("failed to set CURL options"); + }); + } + return; + } // Perform the request CURLcode res = curl_easy_perform(curl); diff --git a/libs/server-sent-events/src/curl_client.hpp b/libs/server-sent-events/src/curl_client.hpp index bde96a49b..2111c9a9b 100644 --- a/libs/server-sent-events/src/curl_client.hpp +++ b/libs/server-sent-events/src/curl_client.hpp @@ -63,7 +63,7 @@ class CurlClient : public Client, void report_error(Error error); std::string build_url() const; - struct curl_slist* setup_curl_options(CURL* curl); + bool setup_curl_options(CURL* curl, struct curl_slist** headers); bool handle_redirect(long response_code, CURL* curl); static int ProgressCallback(void* clientp, curl_off_t dltotal, curl_off_t dlnow, From 185d668449f70bd0e741f1eba89f9bee68fc86cf Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 14 Oct 2025 12:54:43 -0700 Subject: [PATCH 49/90] Build tests and contract tests with and without CURL. --- .github/actions/ci/action.yml | 34 +++++++++++++++++--- .github/workflows/client.yml | 60 +++++++++++++++++++++++++++++++++++ .github/workflows/server.yml | 59 ++++++++++++++++++++++++++++++++++ .github/workflows/sse.yml | 32 +++++++++++++++++++ scripts/build.sh | 12 +++++-- 5 files changed, 190 insertions(+), 7 deletions(-) diff --git a/.github/actions/ci/action.yml b/.github/actions/ci/action.yml index f03a5d88e..49ea82113 100644 --- a/.github/actions/ci/action.yml +++ b/.github/actions/ci/action.yml @@ -29,6 +29,10 @@ inputs: description: 'Whether to run ./build-release-windows.sh for the CMake target' required: false default: 'false' + use_curl: + description: 'Whether to enable CURL networking (LD_CURL_NETWORKING=ON)' + required: false + default: 'false' runs: using: composite @@ -44,22 +48,30 @@ runs: - name: Install OpenSSL uses: ./.github/actions/install-openssl id: install-openssl + - name: Install CURL + if: inputs.use_curl == 'true' + uses: ./.github/actions/install-curl + id: install-curl - name: Build Library shell: bash - run: ./scripts/build.sh ${{ inputs.cmake_target }} ON + run: ./scripts/build.sh ${{ inputs.cmake_target }} ON ${{ inputs.use_curl }} env: BOOST_ROOT: ${{ steps.install-boost.outputs.BOOST_ROOT }} Boost_DIR: ${{ steps.install-boost.outputs.Boost_DIR }} OPENSSL_ROOT_DIR: ${{ steps.install-openssl.outputs.OPENSSL_ROOT_DIR }} + CURL_ROOT: ${{ steps.install-curl.outputs.CURL_ROOT }} + CMAKE_PREFIX_PATH: ${{ steps.install-curl.outputs.CURL_ROOT }} - name: Build Tests id: build-tests if: inputs.run_tests == 'true' shell: bash - run: ./scripts/build.sh gtest_${{ inputs.cmake_target }} ON + run: ./scripts/build.sh gtest_${{ inputs.cmake_target }} ON ${{ inputs.use_curl }} env: BOOST_ROOT: ${{ steps.install-boost.outputs.BOOST_ROOT }} Boost_DIR: ${{ steps.install-boost.outputs.Boost_DIR }} OPENSSL_ROOT_DIR: ${{ steps.install-openssl.outputs.OPENSSL_ROOT_DIR }} + CURL_ROOT: ${{ steps.install-curl.outputs.CURL_ROOT }} + CMAKE_PREFIX_PATH: ${{ steps.install-curl.outputs.CURL_ROOT }} - name: Run Tests if: steps.build-tests.outcome == 'success' shell: bash @@ -70,16 +82,30 @@ runs: - name: Simulate Release (Linux/MacOS) if: inputs.simulate_release == 'true' shell: bash - run: ./scripts/build-release.sh ${{ inputs.cmake_target }} + run: | + if [ "${{ inputs.use_curl }}" == "true" ]; then + ./scripts/build-release.sh ${{ inputs.cmake_target }} --with-curl + else + ./scripts/build-release.sh ${{ inputs.cmake_target }} + fi env: BOOST_ROOT: ${{ steps.install-boost.outputs.BOOST_ROOT }} OPENSSL_ROOT_DIR: ${{ steps.install-openssl.outputs.OPENSSL_ROOT_DIR }} + CURL_ROOT: ${{ steps.install-curl.outputs.CURL_ROOT }} + CMAKE_PREFIX_PATH: ${{ steps.install-curl.outputs.CURL_ROOT }} - name: Simulate Release (Windows) if: inputs.simulate_windows_release == 'true' shell: bash - run: ./scripts/build-release-windows.sh ${{ inputs.cmake_target }} + run: | + if [ "${{ inputs.use_curl }}" == "true" ]; then + ./scripts/build-release-windows.sh ${{ inputs.cmake_target }} --with-curl + else + ./scripts/build-release-windows.sh ${{ inputs.cmake_target }} + fi env: BOOST_ROOT: ${{ steps.install-boost.outputs.BOOST_ROOT }} OPENSSL_ROOT_DIR: ${{ steps.install-openssl.outputs.OPENSSL_ROOT_DIR }} Boost_DIR: 'C:\local\boost_1_87_0\lib64-msvc-14.3\cmake\Boost-1.87.0' + CURL_ROOT: ${{ steps.install-curl.outputs.CURL_ROOT }} + CMAKE_PREFIX_PATH: ${{ steps.install-curl.outputs.CURL_ROOT }} diff --git a/.github/workflows/client.yml b/.github/workflows/client.yml index e949d493b..625245289 100644 --- a/.github/workflows/client.yml +++ b/.github/workflows/client.yml @@ -15,7 +15,26 @@ on: jobs: contract-tests: + runs-on: ubuntu-22.04 + env: + # Port the test service (implemented in this repo) should bind to. + TEST_SERVICE_PORT: 8123 + TEST_SERVICE_BINARY: ./build/contract-tests/client-contract-tests/client-tests + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/ci + with: + cmake_target: client-tests + run_tests: false + - name: 'Launch test service as background task' + run: $TEST_SERVICE_BINARY $TEST_SERVICE_PORT 2>&1 & + - uses: launchdarkly/gh-actions/actions/contract-tests@contract-tests-v1.1.0 + with: + # Inform the test harness of test service's port. + test_service_port: ${{ env.TEST_SERVICE_PORT }} + token: ${{ secrets.GITHUB_TOKEN }} + contract-tests-curl: runs-on: ubuntu-22.04 env: # Port the test service (implemented in this repo) should bind to. @@ -27,6 +46,7 @@ jobs: with: cmake_target: client-tests run_tests: false + use_curl: true - name: 'Launch test service as background task' run: $TEST_SERVICE_BINARY $TEST_SERVICE_PORT 2>&1 & - uses: launchdarkly/gh-actions/actions/contract-tests@contract-tests-v1.1.0 @@ -42,6 +62,17 @@ jobs: with: cmake_target: launchdarkly-cpp-client simulate_release: true + + build-test-curl: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/ci + with: + cmake_target: launchdarkly-cpp-client + use_curl: true + simulate_release: true + build-test-client-mac: runs-on: macos-13 steps: @@ -51,6 +82,18 @@ jobs: cmake_target: launchdarkly-cpp-client platform_version: 12 simulate_release: true + + build-test-client-mac-curl: + runs-on: macos-13 + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/ci + with: + cmake_target: launchdarkly-cpp-client + platform_version: 12 + use_curl: true + simulate_release: true + build-test-client-windows: runs-on: windows-2022 steps: @@ -66,3 +109,20 @@ jobs: platform_version: 2022 toolset: msvc simulate_windows_release: true + + build-test-client-windows-curl: + runs-on: windows-2022 + steps: + - uses: actions/checkout@v4 + - uses: ilammy/msvc-dev-cmd@v1 + - uses: ./.github/actions/ci + env: + BOOST_LIBRARY_DIR: 'C:\local\boost_1_87_0\lib64-msvc-14.3' + BOOST_LIBRARYDIR: 'C:\local\boost_1_87_0\lib64-msvc-14.3' + Boost_DIR: 'C:\local\boost_1_87_0\lib64-msvc-14.3\cmake\Boost-1.87.0' + with: + cmake_target: launchdarkly-cpp-client + platform_version: 2022 + toolset: msvc + use_curl: true + simulate_windows_release: true diff --git a/.github/workflows/server.yml b/.github/workflows/server.yml index 408859829..7740e1585 100644 --- a/.github/workflows/server.yml +++ b/.github/workflows/server.yml @@ -34,6 +34,29 @@ jobs: test_service_port: ${{ env.TEST_SERVICE_PORT }} extra_params: '-skip-from ./contract-tests/server-contract-tests/test-suppressions.txt' token: ${{ secrets.GITHUB_TOKEN }} + + contract-tests-curl: + runs-on: ubuntu-22.04 + env: + # Port the test service (implemented in this repo) should bind to. + TEST_SERVICE_PORT: 8123 + TEST_SERVICE_BINARY: ./build/contract-tests/server-contract-tests/server-tests + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/ci + with: + cmake_target: server-tests + run_tests: false + use_curl: true + - name: 'Launch test service as background task' + run: $TEST_SERVICE_BINARY $TEST_SERVICE_PORT 2>&1 & + - uses: launchdarkly/gh-actions/actions/contract-tests@contract-tests-v1.1.0 + with: + # Inform the test harness of test service's port. + test_service_port: ${{ env.TEST_SERVICE_PORT }} + extra_params: '-skip-from ./contract-tests/server-contract-tests/test-suppressions.txt' + token: ${{ secrets.GITHUB_TOKEN }} + build-test-server: runs-on: ubuntu-22.04 steps: @@ -42,6 +65,17 @@ jobs: with: cmake_target: launchdarkly-cpp-server simulate_release: true + + build-test-server-curl: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/ci + with: + cmake_target: launchdarkly-cpp-server + use_curl: true + simulate_release: true + build-test-server-mac: runs-on: macos-13 steps: @@ -51,6 +85,18 @@ jobs: cmake_target: launchdarkly-cpp-server platform_version: 12 simulate_release: true + + build-test-server-mac-curl: + runs-on: macos-13 + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/ci + with: + cmake_target: launchdarkly-cpp-server + platform_version: 12 + use_curl: true + simulate_release: true + build-test-server-windows: runs-on: windows-2022 steps: @@ -62,3 +108,16 @@ jobs: platform_version: 2022 toolset: msvc simulate_windows_release: true + + build-test-server-windows-curl: + runs-on: windows-2022 + steps: + - uses: actions/checkout@v4 + - uses: ilammy/msvc-dev-cmd@v1 + - uses: ./.github/actions/ci + with: + cmake_target: launchdarkly-cpp-server + platform_version: 2022 + toolset: msvc + use_curl: true + simulate_windows_release: true diff --git a/.github/workflows/sse.yml b/.github/workflows/sse.yml index 2576a1e0d..4b10f125a 100644 --- a/.github/workflows/sse.yml +++ b/.github/workflows/sse.yml @@ -18,6 +18,16 @@ jobs: - uses: ./.github/actions/ci with: cmake_target: launchdarkly-sse-client + + build-test-sse-curl: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/ci + with: + cmake_target: launchdarkly-sse-client + use_curl: true + contract-tests: runs-on: ubuntu-22.04 env: @@ -38,3 +48,25 @@ jobs: branch: 'main' test_service_port: ${{ env.TEST_SERVICE_PORT }} token: ${{ secrets.GITHUB_TOKEN }} + + contract-tests-curl: + runs-on: ubuntu-22.04 + env: + # Port the test service (implemented in this repo) should bind to. + TEST_SERVICE_PORT: 8123 + TEST_SERVICE_BINARY: ./build/contract-tests/sse-contract-tests/sse-tests + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/ci + with: + cmake_target: sse-tests + run_tests: false + use_curl: true + - name: 'Launch test service as background task' + run: $TEST_SERVICE_BINARY $TEST_SERVICE_PORT 2>&1 & + - uses: launchdarkly/gh-actions/actions/contract-tests@contract-tests-v1.1.0 + with: + repo: 'sse-contract-tests' + branch: 'main' + test_service_port: ${{ env.TEST_SERVICE_PORT }} + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/scripts/build.sh b/scripts/build.sh index 27967a1ad..331217f34 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -2,10 +2,11 @@ # This script builds a specific cmake target. # This script should be ran from the root directory of the project. -# ./scripts/build.sh my-build-target ON +# ./scripts/build.sh my-build-target ON [true|false] # # $1 the name of the target. For example "launchdarkly-cpp-common". # $2 ON/OFF which enables/disables building in a test configuration (unit tests + contract tests.) +# $3 (optional) true/false to enable/disable CURL networking (LD_CURL_NETWORKING) function cleanup { cd .. @@ -24,12 +25,17 @@ if [ "$1" == "launchdarkly-cpp-server-redis-source" ] || [ "$1" == "gtest_launch build_redis="ON" fi - +# Check for CURL networking option +build_curl="OFF" +if [ "$3" == "true" ]; then + build_curl="ON" +fi cmake -G Ninja -D CMAKE_COMPILE_WARNING_AS_ERROR=TRUE \ -D BUILD_TESTING="$2" \ -D LD_BUILD_UNIT_TESTS="$2" \ -D LD_BUILD_CONTRACT_TESTS="$2" \ - -D LD_BUILD_REDIS_SUPPORT="$build_redis" .. + -D LD_BUILD_REDIS_SUPPORT="$build_redis" \ + -D LD_CURL_NETWORKING="$build_curl" .. cmake --build . --target "$1" From b3cb350dc1b74d8995f3df6a2857cdc7a20508c5 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 14 Oct 2025 13:37:38 -0700 Subject: [PATCH 50/90] Simplify redirect handling. --- libs/server-sent-events/src/curl_client.cpp | 141 ++++++++------------ libs/server-sent-events/src/curl_client.hpp | 11 +- 2 files changed, 60 insertions(+), 92 deletions(-) diff --git a/libs/server-sent-events/src/curl_client.cpp b/libs/server-sent-events/src/curl_client.cpp index 816785cbc..988612a93 100644 --- a/libs/server-sent-events/src/curl_client.cpp +++ b/libs/server-sent-events/src/curl_client.cpp @@ -24,6 +24,12 @@ auto const kDefaultInitialReconnectDelay = std::chrono::seconds(1); // Maximum duration between backoff attempts. auto const kDefaultMaxBackoffDelay = std::chrono::seconds(30); +constexpr auto kCurlTransferContinue = 0; +constexpr auto kCurlTransferAbort = 1; + +constexpr auto kHttpStatusCodeMovedPermanently = 301; +constexpr auto kHttpStatusCodeTemporaryRedirect= 307; + CurlClient::CurlClient(boost::asio::any_io_executor executor, http::request req, std::string host, @@ -80,10 +86,10 @@ CurlClient::~CurlClient() { // Join the request thread if it exists and is joinable { - std::lock_guard lock(request_thread_mutex_); + std::lock_guard lock(request_thread_mutex_); if (request_thread_ && request_thread_->joinable()) { // Release lock before joining to avoid holding mutex during join - std::unique_ptr thread_to_join = std::move(request_thread_); + const std::unique_ptr thread_to_join = std::move(request_thread_); request_thread_mutex_.unlock(); thread_to_join->join(); request_thread_mutex_.lock(); @@ -104,7 +110,7 @@ void CurlClient::do_run() { // Start request in a separate thread since CURL blocks { - std::lock_guard request_thread_guard(request_thread_mutex_); + std::lock_guard request_thread_guard(request_thread_mutex_); // Join any previous thread before starting a new one if (request_thread_ && request_thread_->joinable()) { @@ -133,12 +139,12 @@ void CurlClient::async_backoff(std::string const& reason) { backoff_timer_.expires_after(backoff_.delay()); backoff_timer_.async_wait([self = shared_from_this()]( - boost::system::error_code ec) { + const boost::system::error_code& ec) { self->on_backoff(ec); }); } -void CurlClient::on_backoff(boost::system::error_code ec) { +void CurlClient::on_backoff(const boost::system::error_code& ec) { if (ec == boost::asio::error::operation_aborted || shutting_down_) { return; } @@ -146,7 +152,7 @@ void CurlClient::on_backoff(boost::system::error_code ec) { } std::string CurlClient::build_url() const { - std::string scheme = use_https_ ? "https" : "http"; + const std::string scheme = use_https_ ? "https" : "http"; std::string url = scheme + "://" + host_; @@ -161,7 +167,7 @@ std::string CurlClient::build_url() const { return url; } -bool CurlClient::setup_curl_options(CURL* curl, struct curl_slist** out_headers) { +bool CurlClient::setup_curl_options(CURL* curl, curl_slist** out_headers) { // Helper macro to check curl_easy_setopt return values // Returns false on error to signal setup failure #define CURL_SETOPT_CHECK(handle, option, parameter) \ @@ -261,8 +267,9 @@ bool CurlClient::setup_curl_options(CURL* curl, struct curl_slist** out_headers) CURL_SETOPT_CHECK(curl, CURLOPT_OPENSOCKETFUNCTION, OpenSocketCallback); CURL_SETOPT_CHECK(curl, CURLOPT_OPENSOCKETDATA, this); - // Don't follow redirects automatically - we'll handle them ourselves - CURL_SETOPT_CHECK(curl, CURLOPT_FOLLOWLOCATION, 0L); + // Follow redirects + CURL_SETOPT_CHECK(curl, CURLOPT_FOLLOWLOCATION, 1L); + CURL_SETOPT_CHECK(curl, CURLOPT_MAXREDIRS, 20L); #undef CURL_SETOPT_CHECK @@ -270,17 +277,20 @@ bool CurlClient::setup_curl_options(CURL* curl, struct curl_slist** out_headers) return true; } +// Handle CURL progress. +// +// https://curl.se/libcurl/c/CURLOPT_XFERINFOFUNCTION.html int CurlClient::ProgressCallback(void* clientp, curl_off_t dltotal, curl_off_t dlnow, curl_off_t ultotal, curl_off_t ulnow) { auto* client = static_cast(clientp); if (client->shutting_down_) { - return 1; // Abort the transfer + return kCurlTransferAbort; } // Check if we've exceeded the read timeout if (client->effective_read_timeout_) { - auto now = std::chrono::steady_clock::now(); + const auto now = std::chrono::steady_clock::now(); // If download amount has changed, update the last progress time if (dlnow != client->last_download_amount_) { @@ -292,17 +302,20 @@ int CurlClient::ProgressCallback(void* clientp, curl_off_t dltotal, curl_off_t d now - client->last_progress_time_); if (elapsed > *client->effective_read_timeout_) { - return 1; // Abort the transfer + return kCurlTransferAbort; } } } - return 0; // Continue + return kCurlTransferContinue; } +// Handle the curl socket opening. +// +// https://curl.se/libcurl/c/CURLOPT_OPENSOCKETFUNCTION.html curl_socket_t CurlClient::OpenSocketCallback(void* clientp, curlsocktype purpose, - struct curl_sockaddr* address) { + const curl_sockaddr* address) { auto* client = static_cast(clientp); // Create the socket @@ -316,7 +329,10 @@ curl_socket_t CurlClient::OpenSocketCallback(void* clientp, return sockfd; } -size_t CurlClient::WriteCallback(char* data, size_t size, size_t nmemb, +// Callback for writing response data +// +// https://curl.se/libcurl/c/CURLOPT_WRITEFUNCTION.html +size_t CurlClient::WriteCallback(const char* data, size_t size, size_t nmemb, void* userp) { size_t total_size = size * nmemb; auto* client = static_cast(userp); @@ -332,8 +348,8 @@ size_t CurlClient::WriteCallback(char* data, size_t size, size_t nmemb, size_t i = 0; while (i < body.size()) { // Find next line delimiter - size_t delimiter_pos = body.find_first_of("\r\n", i); - size_t append_size = (delimiter_pos == std::string::npos) + const size_t delimiter_pos = body.find_first_of("\r\n", i); + const size_t append_size = (delimiter_pos == std::string::npos) ? (body.size() - i) : (delimiter_pos - i); @@ -407,7 +423,7 @@ size_t CurlClient::WriteCallback(char* data, size_t size, size_t nmemb, } // Parse field - size_t colon_pos = line.find(':'); + const size_t colon_pos = line.find(':'); if (colon_pos == 0) { // Comment line, dispatch it std::string comment = line.substr(1); @@ -458,16 +474,17 @@ size_t CurlClient::WriteCallback(char* data, size_t size, size_t nmemb, return total_size; } -size_t CurlClient::HeaderCallback(char* buffer, size_t size, size_t nitems, +// Callback for reading request headers +// +// https://curl.se/libcurl/c/CURLOPT_HEADERFUNCTION.html +size_t CurlClient::HeaderCallback(const char* buffer, size_t size, size_t nitems, void* userdata) { - size_t total_size = size * nitems; + const size_t total_size = size * nitems; auto* client = static_cast(userdata); - std::string header(buffer, total_size); - // Check for Content-Type header - if (header.find("Content-Type:") == 0 || - header.find("content-type:") == 0) { + if (const std::string header(buffer, total_size); header.find("Content-Type:") == 0 || + header.find("content-type:") == 0) { if (header.find("text/event-stream") == std::string::npos) { client->log_message("warning: unexpected Content-Type: " + header); } @@ -476,43 +493,6 @@ size_t CurlClient::HeaderCallback(char* buffer, size_t size, size_t nitems, return total_size; } -bool CurlClient::handle_redirect(long response_code, CURL* curl) { - // Check if this is a redirect status - if (response_code != 301 && response_code != 307) { - return false; - } - - // Get the Location header - char* location = nullptr; - curl_easy_getinfo(curl, CURLINFO_REDIRECT_URL, &location); - - if (!location || std::string(location).empty()) { - // Invalid redirect, let FoxyClient behavior handle it - return false; - } - - // Parse the redirect URL - auto location_url = boost::urls::parse_uri(location); - if (!location_url) { - report_error(errors::InvalidRedirectLocation{location}); - return true; - } - - // Update host and target - host_ = location_url->host(); - req_.set(http::field::host, host_); - req_.target(location_url->encoded_target()); - - if (location_url->has_port()) { - port_ = location_url->port(); - } else { - port_ = location_url->scheme(); - } - - // Signal that we should retry with the new location - return true; -} - void CurlClient::perform_request() { // RAII guard to clear keepalive when function exits // Since we join the thread before destroying the object, we can safely clear keepalive here @@ -545,7 +525,7 @@ void CurlClient::perform_request() { return; } - struct curl_slist* headers = nullptr; + curl_slist* headers = nullptr; if (!setup_curl_options(curl, &headers)) { // setup_curl_options returned false, indicating an error (it already logged the error) curl_easy_cleanup(curl); @@ -573,33 +553,22 @@ void CurlClient::perform_request() { auto status = static_cast(response_code); auto status_class = http::to_status_class(status); - // Handle redirects - if (status_class == http::status_class::redirection) { - bool should_redirect = handle_redirect(response_code, curl); - curl_easy_cleanup(curl); - - if (should_redirect) { - // Retry with new location - if (!shutting_down_) { - perform_request(); - } - return; - } - // Invalid redirect - report error - if (!shutting_down_) { - boost::asio::post(backoff_timer_.get_executor(), [self = shared_from_this()]() { - self->report_error(errors::NotRedirectable{}); - }); - } - return; - } - curl_easy_cleanup(curl); if (shutting_down_) { return; } + if (status_class == http::status_class::redirection) { + // The internal CURL handling of redirects failed. + // This situation is likely the result of a missing redirect header + // or empty header. + boost::asio::post(backoff_timer_.get_executor(), + [self = shared_from_this()]() { + self->report_error(errors::NotRedirectable{}); + }); + } + // Handle result if (res != CURLE_OK) { if (!shutting_down_) { @@ -681,11 +650,11 @@ void CurlClient::perform_request() { void CurlClient::async_shutdown(std::function completion) { boost::asio::post(backoff_timer_.get_executor(), [self = shared_from_this(), completion = std::move(completion)]() { - self->do_shutdown(std::move(completion)); + self->do_shutdown(completion); }); } -void CurlClient::do_shutdown(std::function completion) { +void CurlClient::do_shutdown(const std::function& completion) { shutting_down_ = true; backoff_timer_.cancel(); @@ -704,7 +673,7 @@ void CurlClient::do_shutdown(std::function completion) { std::lock_guard lock(request_thread_mutex_); if (request_thread_ && request_thread_->joinable()) { // Release lock before joining to avoid holding mutex during join - std::unique_ptr thread_to_join = std::move(request_thread_); + const std::unique_ptr thread_to_join = std::move(request_thread_); request_thread_mutex_.unlock(); thread_to_join->join(); request_thread_mutex_.lock(); diff --git a/libs/server-sent-events/src/curl_client.hpp b/libs/server-sent-events/src/curl_client.hpp index 2111c9a9b..580562e31 100644 --- a/libs/server-sent-events/src/curl_client.hpp +++ b/libs/server-sent-events/src/curl_client.hpp @@ -50,21 +50,20 @@ class CurlClient : public Client, private: void do_run(); - void do_shutdown(std::function completion); + void do_shutdown(const std::function& completion); void async_backoff(std::string const& reason); - void on_backoff(boost::system::error_code ec); + void on_backoff(const boost::system::error_code& ec); void perform_request(); - static size_t WriteCallback(char* data, size_t size, size_t nmemb, void* userp); - static size_t HeaderCallback(char* buffer, size_t size, size_t nitems, void* userdata); - static curl_socket_t OpenSocketCallback(void* clientp, curlsocktype purpose, struct curl_sockaddr* address); + static size_t WriteCallback(const char* data, size_t size, size_t nmemb, void* userp); + static size_t HeaderCallback(const char* buffer, size_t size, size_t nitems, void* userdata); + static curl_socket_t OpenSocketCallback(void* clientp, curlsocktype purpose, const struct curl_sockaddr* address); void log_message(std::string const& message); void report_error(Error error); std::string build_url() const; bool setup_curl_options(CURL* curl, struct curl_slist** headers); - bool handle_redirect(long response_code, CURL* curl); static int ProgressCallback(void* clientp, curl_off_t dltotal, curl_off_t dlnow, curl_off_t ultotal, curl_off_t ulnow); From 602f48f135fe10cf1abcb64c2918221f9597f6ff Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 14 Oct 2025 17:03:51 -0700 Subject: [PATCH 51/90] Refactor SSE curl threading. --- libs/server-sent-events/src/curl_client.cpp | 506 ++++++++++---------- libs/server-sent-events/src/curl_client.hpp | 177 +++++-- 2 files changed, 369 insertions(+), 314 deletions(-) diff --git a/libs/server-sent-events/src/curl_client.cpp b/libs/server-sent-events/src/curl_client.cpp index 988612a93..e909681e2 100644 --- a/libs/server-sent-events/src/curl_client.cpp +++ b/libs/server-sent-events/src/curl_client.cpp @@ -10,7 +10,6 @@ #include namespace launchdarkly::sse { - namespace beast = boost::beast; namespace http = beast::http; @@ -28,7 +27,7 @@ constexpr auto kCurlTransferContinue = 0; constexpr auto kCurlTransferAbort = 1; constexpr auto kHttpStatusCodeMovedPermanently = 301; -constexpr auto kHttpStatusCodeTemporaryRedirect= 307; +constexpr auto kHttpStatusCodeTemporaryRedirect = 307; CurlClient::CurlClient(boost::asio::any_io_executor executor, http::request req, @@ -37,7 +36,8 @@ CurlClient::CurlClient(boost::asio::any_io_executor executor, std::optional connect_timeout, std::optional read_timeout, std::optional write_timeout, - std::optional initial_reconnect_delay, + std::optional + initial_reconnect_delay, Builder::EventReceiver receiver, Builder::LogCallback logger, Builder::ErrorCallback errors, @@ -48,54 +48,54 @@ CurlClient::CurlClient(boost::asio::any_io_executor executor, : host_(std::move(host)), port_(std::move(port)), - req_(std::move(req)), - connect_timeout_(connect_timeout), - read_timeout_(read_timeout), - write_timeout_(write_timeout), event_receiver_(std::move(receiver)), logger_(std::move(logger)), errors_(std::move(errors)), - skip_verify_peer_(skip_verify_peer), - custom_ca_file_(std::move(custom_ca_file)), use_https_(use_https), - proxy_url_(std::move(proxy_url)), + backoff_timer_(std::move(executor)), backoff_(initial_reconnect_delay.value_or(kDefaultInitialReconnectDelay), - kDefaultMaxBackoffDelay), - last_event_id_(std::nullopt), - current_event_(std::nullopt), - shutting_down_(false), - curl_socket_(CURL_SOCKET_BAD), - buffered_line_(std::nullopt), - begin_CR_(false), last_download_amount_(0), - backoff_timer_(std::move(executor)) { + kDefaultMaxBackoffDelay) { + request_context_ = std::make_shared( + build_url(req), + std::move(req), + connect_timeout, + read_timeout, + write_timeout, + std::move(custom_ca_file), + std::move(proxy_url), + skip_verify_peer, + [this](const std::string& message) { + async_backoff(message); + }, + [this](const Event& event) { + event_receiver_(event); + }, + [this](const Error& error) { + report_error(error); + }, + [this]() { + backoff_.succeed(); + }); } CurlClient::~CurlClient() { - shutting_down_ = true; - backoff_timer_.cancel(); - - // Close the socket to abort the CURL operation - curl_socket_t sock = curl_socket_.load(); - if (sock != CURL_SOCKET_BAD) { + { + // When shutting down we want to retain the lock while we set shutdown. + // This will ensure this blocks until any outstanding use of the "this" + // pointer is complete. + std::lock_guard lock(request_context_->mutex_); + request_context_->shutting_down_ = true; + // Close the socket to abort the CURL operation + curl_socket_t sock = request_context_->curl_socket_.load(); + if (sock != CURL_SOCKET_BAD) { #ifdef _WIN32 - closesocket(sock); + closesocket(sock); #else - close(sock); + close(sock); #endif - } - - // Join the request thread if it exists and is joinable - { - std::lock_guard lock(request_thread_mutex_); - if (request_thread_ && request_thread_->joinable()) { - // Release lock before joining to avoid holding mutex during join - const std::unique_ptr thread_to_join = std::move(request_thread_); - request_thread_mutex_.unlock(); - thread_to_join->join(); - request_thread_mutex_.lock(); } - keepalive_.reset(); } + backoff_timer_.cancel(); } void CurlClient::async_connect() { @@ -103,27 +103,18 @@ void CurlClient::async_connect() { [self = shared_from_this()]() { self->do_run(); }); } -void CurlClient::do_run() { - if (shutting_down_) { +void CurlClient::do_run() const { + if (request_context_->shutting_down_) { return; } + auto ctx = request_context_; // Start request in a separate thread since CURL blocks - { - std::lock_guard request_thread_guard(request_thread_mutex_); - - // Join any previous thread before starting a new one - if (request_thread_ && request_thread_->joinable()) { - request_thread_->join(); - } - - // Store a keepalive reference to ensure destructor doesn't run on request thread - keepalive_ = shared_from_this(); + // Capture only raw 'this' pointer, not shared_ptr + std::thread t( + [ctx]() { PerformRequest(ctx); }); - // Capture only raw 'this' pointer, not shared_ptr - request_thread_ = std::make_unique( - [this]() { this->perform_request(); }); - } + t.detach(); } void CurlClient::async_backoff(std::string const& reason) { @@ -132,26 +123,29 @@ void CurlClient::async_backoff(std::string const& reason) { std::stringstream msg; msg << "backing off in (" << std::chrono::duration_cast(backoff_.delay()) - .count() + .count() << ") seconds due to " << reason; log_message(msg.str()); backoff_timer_.expires_after(backoff_.delay()); backoff_timer_.async_wait([self = shared_from_this()]( - const boost::system::error_code& ec) { - self->on_backoff(ec); - }); + const boost::system::error_code& ec) { + self->on_backoff(ec); + }); } -void CurlClient::on_backoff(const boost::system::error_code& ec) { - if (ec == boost::asio::error::operation_aborted || shutting_down_) { - return; +void CurlClient::on_backoff(const boost::system::error_code& ec) const { + { + if (ec == boost::asio::error::operation_aborted || request_context_-> + shutting_down_) { + return; + } } do_run(); } -std::string CurlClient::build_url() const { +std::string CurlClient::build_url(http::request req) const { const std::string scheme = use_https_ ? "https" : "http"; std::string url = scheme + "://" + host_; @@ -162,29 +156,31 @@ std::string CurlClient::build_url() const { url += ":" + port_; } - url += std::string(req_.target()); + url += std::string(req.target()); return url; } -bool CurlClient::setup_curl_options(CURL* curl, curl_slist** out_headers) { +bool CurlClient::SetupCurlOptions(CURL* curl, + curl_slist** out_headers, + RequestContext& context) { // Helper macro to check curl_easy_setopt return values // Returns false on error to signal setup failure - #define CURL_SETOPT_CHECK(handle, option, parameter) \ +#define CURL_SETOPT_CHECK(handle, option, parameter) \ do { \ CURLcode code = curl_easy_setopt(handle, option, parameter); \ if (code != CURLE_OK) { \ - log_message("curl_easy_setopt failed for " #option ": " + \ - std::string(curl_easy_strerror(code))); \ + /*log_message("curl_easy_setopt failed for " #option ": " +*/ \ + /*std::string(curl_easy_strerror(code))); */\ return false; \ } \ } while(0) // Set URL - CURL_SETOPT_CHECK(curl, CURLOPT_URL, build_url().c_str()); + CURL_SETOPT_CHECK(curl, CURLOPT_URL, context.url_.c_str()); // Set HTTP method - switch (req_.method()) { + switch (context.req_.method()) { case http::verb::get: CURL_SETOPT_CHECK(curl, CURLOPT_HTTPGET, 1L); break; @@ -200,22 +196,25 @@ bool CurlClient::setup_curl_options(CURL* curl, curl_slist** out_headers) { } // Set request body if present - if (!req_.body().empty()) { - CURL_SETOPT_CHECK(curl, CURLOPT_POSTFIELDS, req_.body().c_str()); - CURL_SETOPT_CHECK(curl, CURLOPT_POSTFIELDSIZE, req_.body().size()); + if (!context.req_.body().empty()) { + CURL_SETOPT_CHECK(curl, CURLOPT_POSTFIELDS, + context.req_.body().c_str()); + CURL_SETOPT_CHECK(curl, CURLOPT_POSTFIELDSIZE, + context.req_.body().size()); } // Set headers struct curl_slist* headers = nullptr; - for (auto const& field : req_) { + for (auto const& field : context.req_) { std::string header = std::string(field.name_string()) + ": " + std::string(field.value()); headers = curl_slist_append(headers, header.c_str()); } // Add Last-Event-ID if we have one - if (last_event_id_ && !last_event_id_->empty()) { - std::string last_event_header = "Last-Event-ID: " + *last_event_id_; + if (context.last_event_id_ && !context.last_event_id_->empty()) { + std::string last_event_header = + "Last-Event-ID: " + *context.last_event_id_; headers = curl_slist_append(headers, last_event_header.c_str()); } @@ -224,54 +223,56 @@ bool CurlClient::setup_curl_options(CURL* curl, curl_slist** out_headers) { } // Set timeouts with millisecond precision - if (connect_timeout_) { - CURL_SETOPT_CHECK(curl, CURLOPT_CONNECTTIMEOUT_MS, connect_timeout_->count()); + if (context.connect_timeout_) { + CURL_SETOPT_CHECK(curl, CURLOPT_CONNECTTIMEOUT_MS, + context.connect_timeout_->count()); } // For read timeout, use progress callback - if (read_timeout_) { - effective_read_timeout_ = read_timeout_; - last_progress_time_ = std::chrono::steady_clock::now(); - last_download_amount_ = 0; + if (context.read_timeout_) { + context.effective_read_timeout_ = context.read_timeout_; + context.last_progress_time_ = std::chrono::steady_clock::now(); + context.last_download_amount_ = 0; CURL_SETOPT_CHECK(curl, CURLOPT_XFERINFOFUNCTION, ProgressCallback); - CURL_SETOPT_CHECK(curl, CURLOPT_XFERINFODATA, this); + CURL_SETOPT_CHECK(curl, CURLOPT_XFERINFODATA, &context); CURL_SETOPT_CHECK(curl, CURLOPT_NOPROGRESS, 0L); } // Set TLS options - if (skip_verify_peer_) { + if (context.skip_verify_peer_) { CURL_SETOPT_CHECK(curl, CURLOPT_SSL_VERIFYPEER, 0L); CURL_SETOPT_CHECK(curl, CURLOPT_SSL_VERIFYHOST, 0L); } else { CURL_SETOPT_CHECK(curl, CURLOPT_SSL_VERIFYPEER, 1L); CURL_SETOPT_CHECK(curl, CURLOPT_SSL_VERIFYHOST, 2L); - if (custom_ca_file_) { - CURL_SETOPT_CHECK(curl, CURLOPT_CAINFO, custom_ca_file_->c_str()); + if (context.custom_ca_file_) { + CURL_SETOPT_CHECK(curl, CURLOPT_CAINFO, + context.custom_ca_file_->c_str()); } } // Set proxy if configured // When proxy_url_ is set, it takes precedence over environment variables. // Empty string explicitly disables proxy (overrides environment variables). - if (proxy_url_) { - CURL_SETOPT_CHECK(curl, CURLOPT_PROXY, proxy_url_->c_str()); + if (context.proxy_url_) { + CURL_SETOPT_CHECK(curl, CURLOPT_PROXY, context.proxy_url_->c_str()); } // If proxy_url_ is std::nullopt, CURL will use environment variables (default behavior) // Set callbacks CURL_SETOPT_CHECK(curl, CURLOPT_WRITEFUNCTION, WriteCallback); - CURL_SETOPT_CHECK(curl, CURLOPT_WRITEDATA, this); + CURL_SETOPT_CHECK(curl, CURLOPT_WRITEDATA, &context); CURL_SETOPT_CHECK(curl, CURLOPT_HEADERFUNCTION, HeaderCallback); - CURL_SETOPT_CHECK(curl, CURLOPT_HEADERDATA, this); + CURL_SETOPT_CHECK(curl, CURLOPT_HEADERDATA, &context); CURL_SETOPT_CHECK(curl, CURLOPT_OPENSOCKETFUNCTION, OpenSocketCallback); - CURL_SETOPT_CHECK(curl, CURLOPT_OPENSOCKETDATA, this); + CURL_SETOPT_CHECK(curl, CURLOPT_OPENSOCKETDATA, &context); // Follow redirects CURL_SETOPT_CHECK(curl, CURLOPT_FOLLOWLOCATION, 1L); CURL_SETOPT_CHECK(curl, CURLOPT_MAXREDIRS, 20L); - #undef CURL_SETOPT_CHECK +#undef CURL_SETOPT_CHECK *out_headers = headers; return true; @@ -280,28 +281,32 @@ bool CurlClient::setup_curl_options(CURL* curl, curl_slist** out_headers) { // Handle CURL progress. // // https://curl.se/libcurl/c/CURLOPT_XFERINFOFUNCTION.html -int CurlClient::ProgressCallback(void* clientp, curl_off_t dltotal, curl_off_t dlnow, - curl_off_t ultotal, curl_off_t ulnow) { - auto* client = static_cast(clientp); - - if (client->shutting_down_) { +int CurlClient::ProgressCallback(void* clientp, + curl_off_t dltotal, + curl_off_t dlnow, + curl_off_t ultotal, + curl_off_t ulnow) { + auto* context = static_cast(clientp); + + if (context->shutting_down_) { return kCurlTransferAbort; } // Check if we've exceeded the read timeout - if (client->effective_read_timeout_) { + if (context->effective_read_timeout_) { const auto now = std::chrono::steady_clock::now(); // If download amount has changed, update the last progress time - if (dlnow != client->last_download_amount_) { - client->last_download_amount_ = dlnow; - client->last_progress_time_ = now; + if (dlnow != context->last_download_amount_) { + context->last_download_amount_ = dlnow; + context->last_progress_time_ = now; } else { // No new data - check if we've exceeded the timeout - auto elapsed = std::chrono::duration_cast( - now - client->last_progress_time_); + auto elapsed = std::chrono::duration_cast< + std::chrono::milliseconds>( + now - context->last_progress_time_); - if (elapsed > *client->effective_read_timeout_) { + if (elapsed > *context->effective_read_timeout_) { return kCurlTransferAbort; } } @@ -314,16 +319,18 @@ int CurlClient::ProgressCallback(void* clientp, curl_off_t dltotal, curl_off_t d // // https://curl.se/libcurl/c/CURLOPT_OPENSOCKETFUNCTION.html curl_socket_t CurlClient::OpenSocketCallback(void* clientp, - curlsocktype purpose, - const curl_sockaddr* address) { - auto* client = static_cast(clientp); + curlsocktype purpose, + const curl_sockaddr* address) { + auto* context = static_cast(clientp); // Create the socket - curl_socket_t sockfd = socket(address->family, address->socktype, address->protocol); + curl_socket_t sockfd = socket(address->family, address->socktype, + address->protocol); // Store it so we can close it during shutdown if (sockfd != CURL_SOCKET_BAD) { - client->curl_socket_ = sockfd; + std::lock_guard lock(context->mutex_); + context->curl_socket_ = sockfd; } return sockfd; @@ -332,13 +339,15 @@ curl_socket_t CurlClient::OpenSocketCallback(void* clientp, // Callback for writing response data // // https://curl.se/libcurl/c/CURLOPT_WRITEFUNCTION.html -size_t CurlClient::WriteCallback(const char* data, size_t size, size_t nmemb, - void* userp) { +size_t CurlClient::WriteCallback(const char* data, + size_t size, + size_t nmemb, + void* userp) { size_t total_size = size * nmemb; - auto* client = static_cast(userp); + auto* context = static_cast(userp); - if (client->shutting_down_) { - return 0; // Abort the transfer + if (context->shutting_down_) { + return 0; // Abort the transfer } // Parse SSE data @@ -350,14 +359,14 @@ size_t CurlClient::WriteCallback(const char* data, size_t size, size_t nmemb, // Find next line delimiter const size_t delimiter_pos = body.find_first_of("\r\n", i); const size_t append_size = (delimiter_pos == std::string::npos) - ? (body.size() - i) - : (delimiter_pos - i); + ? (body.size() - i) + : (delimiter_pos - i); // Append to buffered line - if (client->buffered_line_.has_value()) { - client->buffered_line_->append(body.substr(i, append_size)); + if (context->buffered_line_.has_value()) { + context->buffered_line_->append(body.substr(i, append_size)); } else { - client->buffered_line_ = std::string(body.substr(i, append_size)); + context->buffered_line_ = std::string(body.substr(i, append_size)); } i += append_size; @@ -368,56 +377,52 @@ size_t CurlClient::WriteCallback(const char* data, size_t size, size_t nmemb, // Handle line delimiters if (body[i] == '\r') { - client->complete_lines_.push_back(*client->buffered_line_); - client->buffered_line_.reset(); - client->begin_CR_ = true; + context->complete_lines_.push_back(*context->buffered_line_); + context->buffered_line_.reset(); + context->begin_CR_ = true; i++; } else if (body[i] == '\n') { - if (client->begin_CR_) { - client->begin_CR_ = false; + if (context->begin_CR_) { + context->begin_CR_ = false; } else { - client->complete_lines_.push_back(*client->buffered_line_); - client->buffered_line_.reset(); + context->complete_lines_.push_back(*context->buffered_line_); + context->buffered_line_.reset(); } i++; } } // Parse completed lines into events - while (!client->complete_lines_.empty()) { - std::string line = std::move(client->complete_lines_.front()); - client->complete_lines_.pop_front(); + while (!context->complete_lines_.empty()) { + std::string line = std::move(context->complete_lines_.front()); + context->complete_lines_.pop_front(); if (line.empty()) { // Empty line indicates end of event - if (client->current_event_) { + if (context->current_event_) { // Trim trailing newline from data - if (!client->current_event_->data.empty() && - client->current_event_->data.back() == '\n') { - client->current_event_->data.pop_back(); + if (!context->current_event_->data.empty() && + context->current_event_->data.back() == '\n') { + context->current_event_->data.pop_back(); } // Update last_event_id_ only when dispatching a completed event - if (client->current_event_->id) { - client->last_event_id_ = client->current_event_->id; + if (context->current_event_->id) { + context->last_event_id_ = context->current_event_->id; } // Dispatch event on executor thread - auto event_data = client->current_event_->data; - auto event_type = client->current_event_->type.empty() + auto event_data = context->current_event_->data; + auto event_type = context->current_event_->type.empty() ? "message" - : client->current_event_->type; - auto event_id = client->current_event_->id; - - boost::asio::post(client->backoff_timer_.get_executor(), - [receiver = client->event_receiver_, - type = std::move(event_type), - data = std::move(event_data), - id = std::move(event_id)]() { - receiver(Event(type, data, id)); - }); - - client->current_event_.reset(); + : context->current_event_->type; + auto event_id = context->current_event_->id; + context->receive(Event( + std::move(event_type), + std::move(event_data), + std::move(event_id))); + + context->current_event_.reset(); } continue; } @@ -427,11 +432,8 @@ size_t CurlClient::WriteCallback(const char* data, size_t size, size_t nmemb, if (colon_pos == 0) { // Comment line, dispatch it std::string comment = line.substr(1); - boost::asio::post(client->backoff_timer_.get_executor(), - [receiver = client->event_receiver_, - comment = std::move(comment)]() { - receiver(Event("comment", comment)); - }); + + context->receive(Event("comment", comment)); continue; } @@ -452,20 +454,20 @@ size_t CurlClient::WriteCallback(const char* data, size_t size, size_t nmemb, } // Initialize event if needed - if (!client->current_event_) { - client->current_event_.emplace(detail::Event{}); - client->current_event_->id = client->last_event_id_; + if (!context->current_event_) { + context->current_event_.emplace(detail::Event{}); + context->current_event_->id = context->last_event_id_; } // Handle field if (field_name == "event") { - client->current_event_->type = field_value; + context->current_event_->type = field_value; } else if (field_name == "data") { - client->current_event_->data += field_value; - client->current_event_->data += '\n'; + context->current_event_->data += field_value; + context->current_event_->data += '\n'; } else if (field_name == "id") { if (field_value.find('\0') == std::string::npos) { - client->current_event_->id = field_value; + context->current_event_->id = field_value; } } // retry field is ignored for now @@ -477,14 +479,17 @@ size_t CurlClient::WriteCallback(const char* data, size_t size, size_t nmemb, // Callback for reading request headers // // https://curl.se/libcurl/c/CURLOPT_HEADERFUNCTION.html -size_t CurlClient::HeaderCallback(const char* buffer, size_t size, size_t nitems, - void* userdata) { +size_t CurlClient::HeaderCallback(const char* buffer, + size_t size, + size_t nitems, + void* userdata) { const size_t total_size = size * nitems; auto* client = static_cast(userdata); // Check for Content-Type header - if (const std::string header(buffer, total_size); header.find("Content-Type:") == 0 || - header.find("content-type:") == 0) { + if (const std::string header(buffer, total_size); + header.find("Content-Type:") == 0 || + header.find("content-type:") == 0) { if (header.find("text/event-stream") == std::string::npos) { client->log_message("warning: unexpected Content-Type: " + header); } @@ -493,47 +498,37 @@ size_t CurlClient::HeaderCallback(const char* buffer, size_t size, size_t nitems return total_size; } -void CurlClient::perform_request() { - // RAII guard to clear keepalive when function exits - // Since we join the thread before destroying the object, we can safely clear keepalive here - struct KeepaliveGuard { - CurlClient* client; - explicit KeepaliveGuard(CurlClient* c) : client(c) {} - ~KeepaliveGuard() { - std::lock_guard lock(client->request_thread_mutex_); - client->keepalive_.reset(); - } - } guard(this); - - if (shutting_down_) { +void CurlClient::PerformRequest(std::shared_ptr context) { + if (context->shutting_down_) { return; } // Clear parser state for new connection - buffered_line_.reset(); - complete_lines_.clear(); - current_event_.reset(); - begin_CR_ = false; + context->buffered_line_.reset(); + context->complete_lines_.clear(); + context->current_event_.reset(); + context->begin_CR_ = false; CURL* curl = curl_easy_init(); if (!curl) { - if (!shutting_down_) { - boost::asio::post(backoff_timer_.get_executor(), [self = shared_from_this()]() { - self->async_backoff("failed to initialize CURL"); - }); + if (context->shutting_down_) { + return; } + + context->backoff("failed to initialize CURL"); return; } curl_slist* headers = nullptr; - if (!setup_curl_options(curl, &headers)) { + if (!SetupCurlOptions(curl, &headers, *context)) { // setup_curl_options returned false, indicating an error (it already logged the error) curl_easy_cleanup(curl); - if (!shutting_down_) { - boost::asio::post(backoff_timer_.get_executor(), [self = shared_from_this()]() { - self->async_backoff("failed to set CURL options"); - }); + + if (context->shutting_down_) { + return; } + + context->backoff("failed to set CURL options"); return; } @@ -555,7 +550,7 @@ void CurlClient::perform_request() { curl_easy_cleanup(curl); - if (shutting_down_) { + if (context->shutting_down_) { return; } @@ -563,57 +558,52 @@ void CurlClient::perform_request() { // The internal CURL handling of redirects failed. // This situation is likely the result of a missing redirect header // or empty header. - boost::asio::post(backoff_timer_.get_executor(), - [self = shared_from_this()]() { - self->report_error(errors::NotRedirectable{}); - }); + context->error(errors::NotRedirectable{}); } // Handle result if (res != CURLE_OK) { - if (!shutting_down_) { - // Check if the error was due to progress callback aborting (read timeout) - if (res == CURLE_ABORTED_BY_CALLBACK && effective_read_timeout_) { - boost::asio::post(backoff_timer_.get_executor(), [self = shared_from_this()]() { - self->report_error(errors::ReadTimeout{self->read_timeout_}); - self->async_backoff("aborting read of response body (timeout)"); - }); - } else { - std::string error_msg = "CURL error: " + std::string(curl_easy_strerror(res)); - boost::asio::post(backoff_timer_.get_executor(), [self = shared_from_this(), - error_msg = std::move(error_msg)]() { - self->async_backoff(error_msg); - }); - } + if (context->shutting_down_) { + return; + } + + // Check if the error was due to progress callback aborting (read timeout) + if (res == CURLE_ABORTED_BY_CALLBACK && context-> + effective_read_timeout_) { + context->error(errors::ReadTimeout{ + context->read_timeout_ + }); + context->backoff("aborting read of response body (timeout)"); + } else { + std::string error_msg = "CURL error: " + std::string( + curl_easy_strerror(res)); + context->backoff(error_msg); } + return; } if (status_class == http::status_class::successful) { if (status == http::status::no_content) { - if (!shutting_down_) { - boost::asio::post(backoff_timer_.get_executor(), [self = shared_from_this()]() { - self->report_error(errors::UnrecoverableClientError{http::status::no_content}); - }); + if (!context->shutting_down_) { + context->error(errors::UnrecoverableClientError{ + http::status::no_content}); } return; } - if (!shutting_down_) { - log_message("connected"); + if (!context->shutting_down_) { + // log_message("connected"); } - backoff_.succeed(); + context->resetBackoff_(); // Connection ended normally, reconnect - if (!shutting_down_) { - boost::asio::post(backoff_timer_.get_executor(), - [self = shared_from_this()]() { - self->async_backoff("connection closed normally"); - }); + if (!context->shutting_down_) { + context->backoff("connection closed normally"); } return; } if (status_class == http::status_class::client_error) { - if (!shutting_down_) { + if (!context->shutting_down_) { bool recoverable = (status == http::status::bad_request || status == http::status::request_timeout || status == http::status::too_many_requests); @@ -621,27 +611,22 @@ void CurlClient::perform_request() { if (recoverable) { std::stringstream ss; ss << "HTTP status " << static_cast(status); - boost::asio::post(backoff_timer_.get_executor(), [self = shared_from_this(), - reason = ss.str()]() { - self->async_backoff(reason); - }); + context->backoff(ss.str()); } else { - boost::asio::post(backoff_timer_.get_executor(), [self = shared_from_this(), status]() { - self->report_error(errors::UnrecoverableClientError{status}); - }); + context->error(errors::UnrecoverableClientError{ + status}); } } return; } - // Server error or other - backoff and retry - if (!shutting_down_) { - std::stringstream ss; - ss << "HTTP status " << static_cast(status); - boost::asio::post(backoff_timer_.get_executor(), - [self = shared_from_this(), reason = ss.str()]() { - self->async_backoff(reason); - }); + { + // Server error or other - backoff and retry + if (!context->shutting_down_) { + std::stringstream ss; + ss << "HTTP status " << static_cast(status); + context->backoff(ss.str()); + } } // Keepalive will be cleared by guard's destructor when function exits @@ -649,37 +634,26 @@ void CurlClient::perform_request() { void CurlClient::async_shutdown(std::function completion) { boost::asio::post(backoff_timer_.get_executor(), [self = shared_from_this(), - completion = std::move(completion)]() { - self->do_shutdown(completion); - }); + completion = std::move(completion)]() { + self->do_shutdown(completion); + }); } void CurlClient::do_shutdown(const std::function& completion) { - shutting_down_ = true; - backoff_timer_.cancel(); - - // Close the socket to abort the CURL operation - curl_socket_t sock = curl_socket_.load(); - if (sock != CURL_SOCKET_BAD) { + request_context_->shutting_down_ = true; + { + std::lock_guard lock(request_context_->mutex_); + // Close the socket to abort the CURL operation + curl_socket_t sock = request_context_->curl_socket_.load(); + if (sock != CURL_SOCKET_BAD) { #ifdef _WIN32 - closesocket(sock); + closesocket(sock); #else - close(sock); + close(sock); #endif - } - - // Join the request thread if it exists and is joinable - { - std::lock_guard lock(request_thread_mutex_); - if (request_thread_ && request_thread_->joinable()) { - // Release lock before joining to avoid holding mutex during join - const std::unique_ptr thread_to_join = std::move(request_thread_); - request_thread_mutex_.unlock(); - thread_to_join->join(); - request_thread_mutex_.lock(); } - keepalive_.reset(); } + backoff_timer_.cancel(); if (completion) { completion(); @@ -692,11 +666,11 @@ void CurlClient::log_message(std::string const& message) { } void CurlClient::report_error(Error error) { - boost::asio::post(backoff_timer_.get_executor(), [errors = errors_, error = std::move(error)]() { - errors(error); - }); + boost::asio::post(backoff_timer_.get_executor(), + [errors = errors_, error = std::move(error)]() { + errors(error); + }); } +} // namespace launchdarkly::sse -} // namespace launchdarkly::sse - -#endif // LD_CURL_NETWORKING +#endif // LD_CURL_NETWORKING \ No newline at end of file diff --git a/libs/server-sent-events/src/curl_client.hpp b/libs/server-sent-events/src/curl_client.hpp index 580562e31..19ebaaaad 100644 --- a/libs/server-sent-events/src/curl_client.hpp +++ b/libs/server-sent-events/src/curl_client.hpp @@ -18,15 +18,110 @@ #include #include #include +#include namespace launchdarkly::sse { - namespace http = boost::beast::http; namespace net = boost::asio; +struct RequestContext { + + // SSE parser state + std::optional buffered_line_; + std::deque complete_lines_; + bool begin_CR_; + + // Progress tracking for read timeout + std::chrono::steady_clock::time_point last_progress_time_; + curl_off_t last_download_amount_; + std::optional effective_read_timeout_; + + // Only items used by both the curl thread and the executor/main + // thread need to be mutex protected. + std::mutex mutex_; + std::atomic shutting_down_; + std::atomic curl_socket_; + std::function doBackoff_; + std::function onReceive_; + std::function onError_; + std::function resetBackoff_; + // End mutex protected items. + + std::optional last_event_id_; + std::optional current_event_; + + boost::asio::any_io_executor executor_; + http::request req_; + std::string url_; + + std::optional connect_timeout_; + std::optional read_timeout_; + std::optional write_timeout_; + std::optional custom_ca_file_; + std::optional proxy_url_; + + bool skip_verify_peer_; + + void backoff(const std::string& message) { + std::lock_guard lock(mutex_); + if (shutting_down_) { + return; + } + doBackoff_(message); + } + + void error(const Error& error) { + std::lock_guard lock(mutex_); + if (shutting_down_) { + return; + } + onError_(error); + } + + void receive(const Event& event) { + std::lock_guard lock(mutex_); + if (shutting_down_) { + return; + } + onReceive_(event); + } + + RequestContext(std::string url, + http::request req, + std::optional connect_timeout, + std::optional read_timeout, + std::optional write_timeout, + std::optional custom_ca_file, + std::optional proxy_url, + bool skip_verify_peer, + std::function doBackoff, + std::function onReceive, + std::function onError, + std::function resetBackoff + ) : curl_socket_(CURL_SOCKET_BAD), + buffered_line_(std::nullopt), + begin_CR_(false), + last_download_amount_(0), + shutting_down_(false), + last_event_id_(std::nullopt), current_event_(std::nullopt), + req_(std::move(req)), + url_(std::move(url)), + connect_timeout_(connect_timeout), + read_timeout_(read_timeout), + write_timeout_(write_timeout), + custom_ca_file_(std::move(custom_ca_file)), + proxy_url_(std::move(proxy_url)), + skip_verify_peer_(skip_verify_peer), + doBackoff_(std::move(doBackoff)), + onReceive_(std::move(onReceive)), + onError_(std::move(onError)), + resetBackoff_(std::move(resetBackoff)) { + } +}; + class CurlClient : public Client, public std::enable_shared_from_this { - public: +public: CurlClient(boost::asio::any_io_executor executor, http::request req, std::string host, @@ -48,69 +143,55 @@ class CurlClient : public Client, void async_connect() override; void async_shutdown(std::function completion) override; - private: - void do_run(); +private: + void do_run() const; void do_shutdown(const std::function& completion); void async_backoff(std::string const& reason); - void on_backoff(const boost::system::error_code& ec); - void perform_request(); - - static size_t WriteCallback(const char* data, size_t size, size_t nmemb, void* userp); - static size_t HeaderCallback(const char* buffer, size_t size, size_t nitems, void* userdata); - static curl_socket_t OpenSocketCallback(void* clientp, curlsocktype purpose, const struct curl_sockaddr* address); + void on_backoff(const boost::system::error_code& ec) const; + static void PerformRequest( + std::shared_ptr context); + + static size_t WriteCallback(const char* data, + size_t size, + size_t nmemb, + void* userp); + static size_t HeaderCallback(const char* buffer, + size_t size, + size_t nitems, + void* userdata); + static curl_socket_t OpenSocketCallback(void* clientp, + curlsocktype purpose, + const struct curl_sockaddr* + address); void log_message(std::string const& message); void report_error(Error error); - std::string build_url() const; - bool setup_curl_options(CURL* curl, struct curl_slist** headers); + std::string build_url(http::request req) const; + static bool SetupCurlOptions(CURL* curl, + curl_slist** headers, + RequestContext& context); - static int ProgressCallback(void* clientp, curl_off_t dltotal, curl_off_t dlnow, - curl_off_t ultotal, curl_off_t ulnow); + static int ProgressCallback(void* clientp, + curl_off_t dltotal, + curl_off_t dlnow, + curl_off_t ultotal, + curl_off_t ulnow); + + std::shared_ptr request_context_; std::string host_; std::string port_; - http::request req_; - - std::optional connect_timeout_; - std::optional read_timeout_; - std::optional write_timeout_; Builder::EventReceiver event_receiver_; Builder::LogCallback logger_; Builder::ErrorCallback errors_; - bool skip_verify_peer_; - std::optional custom_ca_file_; bool use_https_; - std::optional proxy_url_; + boost::asio::steady_timer backoff_timer_; Backoff backoff_; - - std::optional last_event_id_; - std::optional current_event_; - - std::atomic shutting_down_; - std::atomic curl_socket_; - - std::unique_ptr request_thread_; - std::mutex request_thread_mutex_; - - // Keepalive reference to prevent destructor from running on request thread - std::shared_ptr keepalive_; - - // SSE parser state - std::optional buffered_line_; - std::deque complete_lines_; - bool begin_CR_; - - // Progress tracking for read timeout - std::chrono::steady_clock::time_point last_progress_time_; - curl_off_t last_download_amount_; - std::optional effective_read_timeout_; - boost::asio::steady_timer backoff_timer_; }; +} // namespace launchdarkly::sse -} // namespace launchdarkly::sse - -#endif // LD_CURL_NETWORKING +#endif // LD_CURL_NETWORKING \ No newline at end of file From 273fce42e454b523459718d98bb7e77d130a06c7 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 15 Oct 2025 07:25:30 -0700 Subject: [PATCH 52/90] Use posts --- libs/server-sent-events/src/curl_client.cpp | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/libs/server-sent-events/src/curl_client.cpp b/libs/server-sent-events/src/curl_client.cpp index e909681e2..1ec1f2d07 100644 --- a/libs/server-sent-events/src/curl_client.cpp +++ b/libs/server-sent-events/src/curl_client.cpp @@ -65,16 +65,24 @@ CurlClient::CurlClient(boost::asio::any_io_executor executor, std::move(proxy_url), skip_verify_peer, [this](const std::string& message) { - async_backoff(message); + boost::asio::post(backoff_timer_.get_executor(), [this, message]() { + async_backoff(message); + }); }, [this](const Event& event) { - event_receiver_(event); + boost::asio::post(backoff_timer_.get_executor(), [this, event]() { + event_receiver_(event); + }); }, [this](const Error& error) { - report_error(error); + boost::asio::post(backoff_timer_.get_executor(), [this, error]() { + report_error(error); + }); }, [this]() { - backoff_.succeed(); + boost::asio::post(backoff_timer_.get_executor(), [this]() { + backoff_.succeed(); + }); }); } From 62536728e2e41d40d402a369b0dcc23efbe68e22 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 15 Oct 2025 09:35:19 -0700 Subject: [PATCH 53/90] Cleanup the RequestContext. --- libs/server-sent-events/src/curl_client.cpp | 197 +++++++--------- libs/server-sent-events/src/curl_client.hpp | 239 ++++++++++++-------- 2 files changed, 230 insertions(+), 206 deletions(-) diff --git a/libs/server-sent-events/src/curl_client.cpp b/libs/server-sent-events/src/curl_client.cpp index 1ec1f2d07..95c38cc22 100644 --- a/libs/server-sent-events/src/curl_client.cpp +++ b/libs/server-sent-events/src/curl_client.cpp @@ -4,7 +4,6 @@ #include #include -#include #include #include @@ -83,26 +82,13 @@ CurlClient::CurlClient(boost::asio::any_io_executor executor, boost::asio::post(backoff_timer_.get_executor(), [this]() { backoff_.succeed(); }); + }, [this](const std::string& message) { + log_message(message); }); } CurlClient::~CurlClient() { - { - // When shutting down we want to retain the lock while we set shutdown. - // This will ensure this blocks until any outstanding use of the "this" - // pointer is complete. - std::lock_guard lock(request_context_->mutex_); - request_context_->shutting_down_ = true; - // Close the socket to abort the CURL operation - curl_socket_t sock = request_context_->curl_socket_.load(); - if (sock != CURL_SOCKET_BAD) { -#ifdef _WIN32 - closesocket(sock); -#else - close(sock); -#endif - } - } + request_context_->shutdown(); backoff_timer_.cancel(); } @@ -112,7 +98,7 @@ void CurlClient::async_connect() { } void CurlClient::do_run() const { - if (request_context_->shutting_down_) { + if (request_context_->is_shutting_down()) { return; } @@ -146,14 +132,15 @@ void CurlClient::async_backoff(std::string const& reason) { void CurlClient::on_backoff(const boost::system::error_code& ec) const { { if (ec == boost::asio::error::operation_aborted || request_context_-> - shutting_down_) { + is_shutting_down()) { return; } } do_run(); } -std::string CurlClient::build_url(http::request req) const { +std::string CurlClient::build_url( + const http::request& req) const { const std::string scheme = use_https_ ? "https" : "http"; std::string url = scheme + "://" + host_; @@ -178,17 +165,17 @@ bool CurlClient::SetupCurlOptions(CURL* curl, do { \ CURLcode code = curl_easy_setopt(handle, option, parameter); \ if (code != CURLE_OK) { \ - /*log_message("curl_easy_setopt failed for " #option ": " +*/ \ - /*std::string(curl_easy_strerror(code))); */\ + context.log_message("curl_easy_setopt failed for " #option ": " + \ + std::string(curl_easy_strerror(code))); \ return false; \ } \ } while(0) // Set URL - CURL_SETOPT_CHECK(curl, CURLOPT_URL, context.url_.c_str()); + CURL_SETOPT_CHECK(curl, CURLOPT_URL, context.url.c_str()); // Set HTTP method - switch (context.req_.method()) { + switch (context.req.method()) { case http::verb::get: CURL_SETOPT_CHECK(curl, CURLOPT_HTTPGET, 1L); break; @@ -204,25 +191,25 @@ bool CurlClient::SetupCurlOptions(CURL* curl, } // Set request body if present - if (!context.req_.body().empty()) { + if (!context.req.body().empty()) { CURL_SETOPT_CHECK(curl, CURLOPT_POSTFIELDS, - context.req_.body().c_str()); + context.req.body().c_str()); CURL_SETOPT_CHECK(curl, CURLOPT_POSTFIELDSIZE, - context.req_.body().size()); + context.req.body().size()); } // Set headers struct curl_slist* headers = nullptr; - for (auto const& field : context.req_) { + for (auto const& field : context.req) { std::string header = std::string(field.name_string()) + ": " + std::string(field.value()); headers = curl_slist_append(headers, header.c_str()); } // Add Last-Event-ID if we have one - if (context.last_event_id_ && !context.last_event_id_->empty()) { + if (context.last_event_id && !context.last_event_id->empty()) { std::string last_event_header = - "Last-Event-ID: " + *context.last_event_id_; + "Last-Event-ID: " + *context.last_event_id; headers = curl_slist_append(headers, last_event_header.c_str()); } @@ -231,40 +218,39 @@ bool CurlClient::SetupCurlOptions(CURL* curl, } // Set timeouts with millisecond precision - if (context.connect_timeout_) { + if (context.connect_timeout) { CURL_SETOPT_CHECK(curl, CURLOPT_CONNECTTIMEOUT_MS, - context.connect_timeout_->count()); + context.connect_timeout->count()); } // For read timeout, use progress callback - if (context.read_timeout_) { - context.effective_read_timeout_ = context.read_timeout_; - context.last_progress_time_ = std::chrono::steady_clock::now(); - context.last_download_amount_ = 0; + if (context.read_timeout) { + context.last_progress_time = std::chrono::steady_clock::now(); + context.last_download_amount = 0; CURL_SETOPT_CHECK(curl, CURLOPT_XFERINFOFUNCTION, ProgressCallback); CURL_SETOPT_CHECK(curl, CURLOPT_XFERINFODATA, &context); CURL_SETOPT_CHECK(curl, CURLOPT_NOPROGRESS, 0L); } // Set TLS options - if (context.skip_verify_peer_) { + if (context.skip_verify_peer) { CURL_SETOPT_CHECK(curl, CURLOPT_SSL_VERIFYPEER, 0L); CURL_SETOPT_CHECK(curl, CURLOPT_SSL_VERIFYHOST, 0L); } else { CURL_SETOPT_CHECK(curl, CURLOPT_SSL_VERIFYPEER, 1L); CURL_SETOPT_CHECK(curl, CURLOPT_SSL_VERIFYHOST, 2L); - if (context.custom_ca_file_) { + if (context.custom_ca_file) { CURL_SETOPT_CHECK(curl, CURLOPT_CAINFO, - context.custom_ca_file_->c_str()); + context.custom_ca_file->c_str()); } } // Set proxy if configured // When proxy_url_ is set, it takes precedence over environment variables. // Empty string explicitly disables proxy (overrides environment variables). - if (context.proxy_url_) { - CURL_SETOPT_CHECK(curl, CURLOPT_PROXY, context.proxy_url_->c_str()); + if (context.proxy_url) { + CURL_SETOPT_CHECK(curl, CURLOPT_PROXY, context.proxy_url->c_str()); } // If proxy_url_ is std::nullopt, CURL will use environment variables (default behavior) @@ -296,25 +282,25 @@ int CurlClient::ProgressCallback(void* clientp, curl_off_t ulnow) { auto* context = static_cast(clientp); - if (context->shutting_down_) { + if (context->is_shutting_down()) { return kCurlTransferAbort; } // Check if we've exceeded the read timeout - if (context->effective_read_timeout_) { + if (context->read_timeout) { const auto now = std::chrono::steady_clock::now(); // If download amount has changed, update the last progress time - if (dlnow != context->last_download_amount_) { - context->last_download_amount_ = dlnow; - context->last_progress_time_ = now; + if (dlnow != context->last_download_amount) { + context->last_download_amount = dlnow; + context->last_progress_time = now; } else { // No new data - check if we've exceeded the timeout auto elapsed = std::chrono::duration_cast< std::chrono::milliseconds>( - now - context->last_progress_time_); + now - context->last_progress_time); - if (elapsed > *context->effective_read_timeout_) { + if (elapsed > *context->read_timeout) { return kCurlTransferAbort; } } @@ -337,8 +323,7 @@ curl_socket_t CurlClient::OpenSocketCallback(void* clientp, // Store it so we can close it during shutdown if (sockfd != CURL_SOCKET_BAD) { - std::lock_guard lock(context->mutex_); - context->curl_socket_ = sockfd; + context->set_curl_socket(sockfd); } return sockfd; @@ -354,7 +339,7 @@ size_t CurlClient::WriteCallback(const char* data, size_t total_size = size * nmemb; auto* context = static_cast(userp); - if (context->shutting_down_) { + if (context->is_shutting_down()) { return 0; // Abort the transfer } @@ -371,10 +356,10 @@ size_t CurlClient::WriteCallback(const char* data, : (delimiter_pos - i); // Append to buffered line - if (context->buffered_line_.has_value()) { - context->buffered_line_->append(body.substr(i, append_size)); + if (context->buffered_line.has_value()) { + context->buffered_line->append(body.substr(i, append_size)); } else { - context->buffered_line_ = std::string(body.substr(i, append_size)); + context->buffered_line = std::string(body.substr(i, append_size)); } i += append_size; @@ -385,52 +370,52 @@ size_t CurlClient::WriteCallback(const char* data, // Handle line delimiters if (body[i] == '\r') { - context->complete_lines_.push_back(*context->buffered_line_); - context->buffered_line_.reset(); - context->begin_CR_ = true; + context->complete_lines.push_back(*context->buffered_line); + context->buffered_line.reset(); + context->begin_CR = true; i++; } else if (body[i] == '\n') { - if (context->begin_CR_) { - context->begin_CR_ = false; + if (context->begin_CR) { + context->begin_CR = false; } else { - context->complete_lines_.push_back(*context->buffered_line_); - context->buffered_line_.reset(); + context->complete_lines.push_back(*context->buffered_line); + context->buffered_line.reset(); } i++; } } // Parse completed lines into events - while (!context->complete_lines_.empty()) { - std::string line = std::move(context->complete_lines_.front()); - context->complete_lines_.pop_front(); + while (!context->complete_lines.empty()) { + std::string line = std::move(context->complete_lines.front()); + context->complete_lines.pop_front(); if (line.empty()) { // Empty line indicates end of event - if (context->current_event_) { + if (context->current_event) { // Trim trailing newline from data - if (!context->current_event_->data.empty() && - context->current_event_->data.back() == '\n') { - context->current_event_->data.pop_back(); + if (!context->current_event->data.empty() && + context->current_event->data.back() == '\n') { + context->current_event->data.pop_back(); } // Update last_event_id_ only when dispatching a completed event - if (context->current_event_->id) { - context->last_event_id_ = context->current_event_->id; + if (context->current_event->id) { + context->last_event_id = context->current_event->id; } // Dispatch event on executor thread - auto event_data = context->current_event_->data; - auto event_type = context->current_event_->type.empty() + auto event_data = context->current_event->data; + auto event_type = context->current_event->type.empty() ? "message" - : context->current_event_->type; - auto event_id = context->current_event_->id; + : context->current_event->type; + auto event_id = context->current_event->id; context->receive(Event( std::move(event_type), std::move(event_data), std::move(event_id))); - context->current_event_.reset(); + context->current_event.reset(); } continue; } @@ -462,20 +447,20 @@ size_t CurlClient::WriteCallback(const char* data, } // Initialize event if needed - if (!context->current_event_) { - context->current_event_.emplace(detail::Event{}); - context->current_event_->id = context->last_event_id_; + if (!context->current_event) { + context->current_event.emplace(detail::Event{}); + context->current_event->id = context->last_event_id; } // Handle field if (field_name == "event") { - context->current_event_->type = field_value; + context->current_event->type = field_value; } else if (field_name == "data") { - context->current_event_->data += field_value; - context->current_event_->data += '\n'; + context->current_event->data += field_value; + context->current_event->data += '\n'; } else if (field_name == "id") { if (field_value.find('\0') == std::string::npos) { - context->current_event_->id = field_value; + context->current_event->id = field_value; } } // retry field is ignored for now @@ -507,19 +492,19 @@ size_t CurlClient::HeaderCallback(const char* buffer, } void CurlClient::PerformRequest(std::shared_ptr context) { - if (context->shutting_down_) { + if (context->is_shutting_down()) { return; } // Clear parser state for new connection - context->buffered_line_.reset(); - context->complete_lines_.clear(); - context->current_event_.reset(); - context->begin_CR_ = false; + context->buffered_line.reset(); + context->complete_lines.clear(); + context->current_event.reset(); + context->begin_CR = false; CURL* curl = curl_easy_init(); if (!curl) { - if (context->shutting_down_) { + if (context->is_shutting_down()) { return; } @@ -532,7 +517,7 @@ void CurlClient::PerformRequest(std::shared_ptr context) { // setup_curl_options returned false, indicating an error (it already logged the error) curl_easy_cleanup(curl); - if (context->shutting_down_) { + if (context->is_shutting_down()) { return; } @@ -558,7 +543,7 @@ void CurlClient::PerformRequest(std::shared_ptr context) { curl_easy_cleanup(curl); - if (context->shutting_down_) { + if (context->is_shutting_down()) { return; } @@ -571,15 +556,15 @@ void CurlClient::PerformRequest(std::shared_ptr context) { // Handle result if (res != CURLE_OK) { - if (context->shutting_down_) { + if (context->is_shutting_down()) { return; } // Check if the error was due to progress callback aborting (read timeout) if (res == CURLE_ABORTED_BY_CALLBACK && context-> - effective_read_timeout_) { + read_timeout) { context->error(errors::ReadTimeout{ - context->read_timeout_ + context->read_timeout }); context->backoff("aborting read of response body (timeout)"); } else { @@ -593,25 +578,25 @@ void CurlClient::PerformRequest(std::shared_ptr context) { if (status_class == http::status_class::successful) { if (status == http::status::no_content) { - if (!context->shutting_down_) { + if (!context->is_shutting_down()) { context->error(errors::UnrecoverableClientError{ http::status::no_content}); } return; } - if (!context->shutting_down_) { + if (!context->is_shutting_down()) { // log_message("connected"); } - context->resetBackoff_(); + context->reset_backoff(); // Connection ended normally, reconnect - if (!context->shutting_down_) { + if (!context->is_shutting_down()) { context->backoff("connection closed normally"); } return; } if (status_class == http::status_class::client_error) { - if (!context->shutting_down_) { + if (!context->is_shutting_down()) { bool recoverable = (status == http::status::bad_request || status == http::status::request_timeout || status == http::status::too_many_requests); @@ -630,7 +615,7 @@ void CurlClient::PerformRequest(std::shared_ptr context) { { // Server error or other - backoff and retry - if (!context->shutting_down_) { + if (!context->is_shutting_down()) { std::stringstream ss; ss << "HTTP status " << static_cast(status); context->backoff(ss.str()); @@ -648,19 +633,7 @@ void CurlClient::async_shutdown(std::function completion) { } void CurlClient::do_shutdown(const std::function& completion) { - request_context_->shutting_down_ = true; - { - std::lock_guard lock(request_context_->mutex_); - // Close the socket to abort the CURL operation - curl_socket_t sock = request_context_->curl_socket_.load(); - if (sock != CURL_SOCKET_BAD) { -#ifdef _WIN32 - closesocket(sock); -#else - close(sock); -#endif - } - } + request_context_->shutdown(); backoff_timer_.cancel(); if (completion) { diff --git a/libs/server-sent-events/src/curl_client.hpp b/libs/server-sent-events/src/curl_client.hpp index 19ebaaaad..ffcfd10f4 100644 --- a/libs/server-sent-events/src/curl_client.hpp +++ b/libs/server-sent-events/src/curl_client.hpp @@ -18,109 +18,160 @@ #include #include #include -#include namespace launchdarkly::sse { -namespace http = boost::beast::http; +namespace http = beast::http; namespace net = boost::asio; -struct RequestContext { - - // SSE parser state - std::optional buffered_line_; - std::deque complete_lines_; - bool begin_CR_; - - // Progress tracking for read timeout - std::chrono::steady_clock::time_point last_progress_time_; - curl_off_t last_download_amount_; - std::optional effective_read_timeout_; - - // Only items used by both the curl thread and the executor/main - // thread need to be mutex protected. - std::mutex mutex_; - std::atomic shutting_down_; - std::atomic curl_socket_; - std::function doBackoff_; - std::function onReceive_; - std::function onError_; - std::function resetBackoff_; - // End mutex protected items. - - std::optional last_event_id_; - std::optional current_event_; - - boost::asio::any_io_executor executor_; - http::request req_; - std::string url_; - - std::optional connect_timeout_; - std::optional read_timeout_; - std::optional write_timeout_; - std::optional custom_ca_file_; - std::optional proxy_url_; - - bool skip_verify_peer_; - - void backoff(const std::string& message) { - std::lock_guard lock(mutex_); - if (shutting_down_) { - return; +/** + * The lifecycle of the CurlClient is managed in an RAII manner. This introduces + * some complexity with interaction with CURL, which requires a thread to be + * driven. The desutruction of the CurlClient will signal that the CURL thread + * should stop. Though depending on the operation it may linger for some + * time. + * + * The CurlClient itself is not accessible from the CURL thread and instead + * all their shared state is in a RequestContext. The lifetime of this context + * can exceed that of the CurlClient while CURL shuts down. This approach + * prevents the lifetime of the CurlClient being attached to that of the + * curl thread. + */ +class CurlClient final : public Client, + public std::enable_shared_from_this { + class RequestContext { + // Only items used by both the curl thread and the executor/main + // thread need to be mutex protected. + std::mutex mutex_; + std::atomic shutting_down_; + std::atomic curl_socket_; + std::function do_backoff_; + std::function on_receive_; + std::function on_error_; + std::function reset_backoff_; + std::function log_message_; + // End mutex protected items. + + public: + // SSE parser state + std::optional buffered_line; + std::deque complete_lines; + bool begin_CR; + + // Progress tracking for read timeout + std::chrono::steady_clock::time_point last_progress_time; + curl_off_t last_download_amount; + + + std::optional last_event_id; + std::optional current_event; + + const http::request req; + const std::string url; + const std::optional connect_timeout; + const std::optional read_timeout; + const std::optional write_timeout; + const std::optional custom_ca_file; + const std::optional proxy_url; + const bool skip_verify_peer; + + void backoff(const std::string& message) { + std::lock_guard lock(mutex_); + if (shutting_down_) { + return; + } + do_backoff_(message); } - doBackoff_(message); - } - void error(const Error& error) { - std::lock_guard lock(mutex_); - if (shutting_down_) { - return; + void error(const Error& error) { + std::lock_guard lock(mutex_); + if (shutting_down_) { + return; + } + on_error_(error); } - onError_(error); - } - void receive(const Event& event) { - std::lock_guard lock(mutex_); - if (shutting_down_) { - return; + void receive(const Event& event) { + std::lock_guard lock(mutex_); + if (shutting_down_) { + return; + } + on_receive_(event); } - onReceive_(event); - } - - RequestContext(std::string url, - http::request req, - std::optional connect_timeout, - std::optional read_timeout, - std::optional write_timeout, - std::optional custom_ca_file, - std::optional proxy_url, - bool skip_verify_peer, - std::function doBackoff, - std::function onReceive, - std::function onError, - std::function resetBackoff - ) : curl_socket_(CURL_SOCKET_BAD), - buffered_line_(std::nullopt), - begin_CR_(false), - last_download_amount_(0), - shutting_down_(false), - last_event_id_(std::nullopt), current_event_(std::nullopt), - req_(std::move(req)), - url_(std::move(url)), - connect_timeout_(connect_timeout), - read_timeout_(read_timeout), - write_timeout_(write_timeout), - custom_ca_file_(std::move(custom_ca_file)), - proxy_url_(std::move(proxy_url)), - skip_verify_peer_(skip_verify_peer), - doBackoff_(std::move(doBackoff)), - onReceive_(std::move(onReceive)), - onError_(std::move(onError)), - resetBackoff_(std::move(resetBackoff)) { - } -}; -class CurlClient : public Client, - public std::enable_shared_from_this { + void reset_backoff() { + std::lock_guard lock(mutex_); + if (shutting_down_) { + return; + } + reset_backoff_(); + } + + void log_message(const std::string& message) { + std::lock_guard lock(mutex_); + if (shutting_down_) { + return; + } + log_message_(message); + } + + bool is_shutting_down() { + return shutting_down_; + } + + void set_curl_socket(curl_socket_t curl_socket) { + std::lock_guard lock(mutex_); + curl_socket_ = curl_socket; + } + + void shutdown() { + std::lock_guard lock(mutex_); + shutting_down_ = true; + if (curl_socket_ != CURL_SOCKET_BAD) { +#ifdef _WIN32 + closesocket(curl_socket_); +#else + close(curl_socket_); +#endif + } + } + + + RequestContext(std::string url, + http::request req, + std::optional connect_timeout, + std::optional read_timeout, + std::optional write_timeout, + std::optional custom_ca_file, + std::optional proxy_url, + bool skip_verify_peer, + std::function doBackoff, + std::function onReceive, + std::function onError, + std::function resetBackoff, + std::function log_message + ) : shutting_down_(false), + curl_socket_(CURL_SOCKET_BAD), + do_backoff_(std::move(doBackoff)), + on_receive_(std::move(onReceive)), + on_error_(std::move(onError)), + reset_backoff_(std::move(resetBackoff)), + log_message_(std::move(log_message)), + buffered_line(std::nullopt), + begin_CR(false), + last_download_amount(0), + last_event_id(std::nullopt), + current_event(std::nullopt), + req(std::move(req)), + url(std::move(url)), + connect_timeout(connect_timeout), + read_timeout(read_timeout), + write_timeout(write_timeout), + custom_ca_file(std::move(custom_ca_file)), + proxy_url(std::move(proxy_url)), + skip_verify_peer(skip_verify_peer) { + } + }; + public: CurlClient(boost::asio::any_io_executor executor, http::request req, @@ -167,7 +218,7 @@ class CurlClient : public Client, void log_message(std::string const& message); void report_error(Error error); - std::string build_url(http::request req) const; + std::string build_url(const http::request& req) const; static bool SetupCurlOptions(CURL* curl, curl_slist** headers, RequestContext& context); From 3597de5d11888fba7c52ad9d64ab51f94d1fdfcf Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 15 Oct 2025 09:52:10 -0700 Subject: [PATCH 54/90] Add support for client-side proxy contract tests. --- .../client-contract-tests/src/entity_manager.cpp | 6 ++++++ contract-tests/client-contract-tests/src/main.cpp | 1 + .../data-model/include/data_model/data_model.hpp | 10 +++++++++- 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/contract-tests/client-contract-tests/src/entity_manager.cpp b/contract-tests/client-contract-tests/src/entity_manager.cpp index 58eef6cfd..2bb833926 100644 --- a/contract-tests/client-contract-tests/src/entity_manager.cpp +++ b/contract-tests/client-contract-tests/src/entity_manager.cpp @@ -38,6 +38,12 @@ std::optional EntityManager::create(ConfigParams const& in) { .PollingBaseUrl(default_endpoints.PollingBaseUrl()) .StreamingBaseUrl(default_endpoints.StreamingBaseUrl()); + if (in.proxy) { + if (in.proxy->httpProxy) { + config_builder.HttpProperties().Proxy(*in.proxy->httpProxy); + } + } + if (in.serviceEndpoints) { if (in.serviceEndpoints->streaming) { endpoints.StreamingBaseUrl(*in.serviceEndpoints->streaming); diff --git a/contract-tests/client-contract-tests/src/main.cpp b/contract-tests/client-contract-tests/src/main.cpp index bf755f418..98a9435f8 100644 --- a/contract-tests/client-contract-tests/src/main.cpp +++ b/contract-tests/client-contract-tests/src/main.cpp @@ -47,6 +47,7 @@ int main(int argc, char* argv[]) { srv.add_capability("tls:skip-verify-peer"); srv.add_capability("tls:custom-ca"); srv.add_capability("client-prereq-events"); + srv.add_capability("http-proxy"); net::signal_set signals{ioc, SIGINT, SIGTERM}; diff --git a/contract-tests/data-model/include/data_model/data_model.hpp b/contract-tests/data-model/include/data_model/data_model.hpp index 12ca2e28d..7346f0e41 100644 --- a/contract-tests/data-model/include/data_model/data_model.hpp +++ b/contract-tests/data-model/include/data_model/data_model.hpp @@ -37,6 +37,12 @@ NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(ConfigTLSParams, skipVerifyPeer, customCAFile); +struct ConfigProxyParams { + std::optional httpProxy; +}; + +NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(ConfigProxyParams, httpProxy); + struct ConfigStreamingParams { std::optional baseUri; std::optional initialRetryDelayMs; @@ -118,6 +124,7 @@ struct ConfigParams { std::optional clientSide; std::optional tags; std::optional tls; + std::optional proxy; }; NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(ConfigParams, @@ -130,7 +137,8 @@ NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(ConfigParams, serviceEndpoints, clientSide, tags, - tls); + tls, + proxy); struct ContextSingleParams { std::optional kind; From 2a49349031557623c22cc6c2214cf8420e9d1ffc Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 15 Oct 2025 10:04:10 -0700 Subject: [PATCH 55/90] Add experimental notice for CURL server-side. Tag server-side artifacts with CURL as experimental. --- .github/actions/sdk-release/action.yml | 67 ++++++++++++++++++++------ README.md | 5 ++ 2 files changed, 58 insertions(+), 14 deletions(-) diff --git a/.github/actions/sdk-release/action.yml b/.github/actions/sdk-release/action.yml index fafc667ad..7ade5eb96 100644 --- a/.github/actions/sdk-release/action.yml +++ b/.github/actions/sdk-release/action.yml @@ -83,13 +83,24 @@ runs: type: 'zip' filename: 'linux-gcc-x64-dynamic.zip' + - name: Determine CURL artifact suffix for server SDK + if: runner.os == 'Linux' + shell: bash + id: curl-suffix-linux + run: | + if [[ "${{ inputs.sdk_cmake_target }}" == "launchdarkly-cpp-server" ]]; then + echo "suffix=-experimental" >> $GITHUB_OUTPUT + else + echo "suffix=" >> $GITHUB_OUTPUT + fi + - name: Archive Release Linux - GCC/x64/Static/CURL if: runner.os == 'Linux' uses: thedoctor0/zip-release@0.7.1 with: path: 'build-static-curl/release' type: 'zip' - filename: 'linux-gcc-x64-static-curl.zip' + filename: 'linux-gcc-x64-static-curl${{ steps.curl-suffix-linux.outputs.suffix }}.zip' - name: Archive Release Linux - GCC/x64/Dynamic/CURL if: runner.os == 'Linux' @@ -97,21 +108,23 @@ runs: with: path: 'build-dynamic-curl/release' type: 'zip' - filename: 'linux-gcc-x64-dynamic-curl.zip' + filename: 'linux-gcc-x64-dynamic-curl${{ steps.curl-suffix-linux.outputs.suffix }}.zip' - name: Hash Linux Build Artifacts for provenance if: runner.os == 'Linux' shell: bash id: hash-linux run: | - echo "hashes-linux=$(sha256sum linux-gcc-x64-static.zip linux-gcc-x64-dynamic.zip linux-gcc-x64-static-curl.zip linux-gcc-x64-dynamic-curl.zip | base64 -w0)" >> "$GITHUB_OUTPUT" + CURL_SUFFIX="${{ steps.curl-suffix-linux.outputs.suffix }}" + echo "hashes-linux=$(sha256sum linux-gcc-x64-static.zip linux-gcc-x64-dynamic.zip linux-gcc-x64-static-curl${CURL_SUFFIX}.zip linux-gcc-x64-dynamic-curl${CURL_SUFFIX}.zip | base64 -w0)" >> "$GITHUB_OUTPUT" - name: Upload Linux Build Artifacts if: runner.os == 'Linux' shell: bash run: | ls - gh release upload ${{ inputs.tag_name }} linux-gcc-x64-static.zip linux-gcc-x64-dynamic.zip linux-gcc-x64-static-curl.zip linux-gcc-x64-dynamic-curl.zip --clobber + CURL_SUFFIX="${{ steps.curl-suffix-linux.outputs.suffix }}" + gh release upload ${{ inputs.tag_name }} linux-gcc-x64-static.zip linux-gcc-x64-dynamic.zip linux-gcc-x64-static-curl${CURL_SUFFIX}.zip linux-gcc-x64-dynamic-curl${CURL_SUFFIX}.zip --clobber env: GH_TOKEN: ${{ inputs.github_token }} @@ -178,13 +191,24 @@ runs: type: 'zip' filename: 'windows-msvc-x64-dynamic-debug.zip' + - name: Determine CURL artifact suffix for server SDK + if: runner.os == 'Windows' + shell: bash + id: curl-suffix-windows + run: | + if [[ "${{ inputs.sdk_cmake_target }}" == "launchdarkly-cpp-server" ]]; then + echo "suffix=-experimental" >> $GITHUB_OUTPUT + else + echo "suffix=" >> $GITHUB_OUTPUT + fi + - name: Archive Release Windows - MSVC/x64/Static/CURL if: runner.os == 'Windows' uses: thedoctor0/zip-release@0.7.1 with: path: 'build-static-curl/release' type: 'zip' - filename: 'windows-msvc-x64-static-curl.zip' + filename: 'windows-msvc-x64-static-curl${{ steps.curl-suffix-windows.outputs.suffix }}.zip' - name: Archive Release Windows - MSVC/x64/Dynamic/CURL if: runner.os == 'Windows' @@ -192,7 +216,7 @@ runs: with: path: 'build-dynamic-curl/release' type: 'zip' - filename: 'windows-msvc-x64-dynamic-curl.zip' + filename: 'windows-msvc-x64-dynamic-curl${{ steps.curl-suffix-windows.outputs.suffix }}.zip' - name: Archive Release Windows - MSVC/x64/Static/Debug/CURL if: runner.os == 'Windows' @@ -200,7 +224,7 @@ runs: with: path: 'build-static-debug-curl/release' type: 'zip' - filename: 'windows-msvc-x64-static-debug-curl.zip' + filename: 'windows-msvc-x64-static-debug-curl${{ steps.curl-suffix-windows.outputs.suffix }}.zip' - name: Archive Release Windows - MSVC/x64/Dynamic/Debug/CURL if: runner.os == 'Windows' @@ -208,21 +232,23 @@ runs: with: path: 'build-dynamic-debug-curl/release' type: 'zip' - filename: 'windows-msvc-x64-dynamic-debug-curl.zip' + filename: 'windows-msvc-x64-dynamic-debug-curl${{ steps.curl-suffix-windows.outputs.suffix }}.zip' - name: Hash Windows Build Artifacts for provenance if: runner.os == 'Windows' shell: bash id: hash-windows run: | - echo "hashes-windows=$(sha256sum windows-msvc-x64-static.zip windows-msvc-x64-dynamic.zip windows-msvc-x64-static-debug.zip windows-msvc-x64-dynamic-debug.zip windows-msvc-x64-static-curl.zip windows-msvc-x64-dynamic-curl.zip windows-msvc-x64-static-debug-curl.zip windows-msvc-x64-dynamic-debug-curl.zip | base64 -w0)" >> "$GITHUB_OUTPUT" + CURL_SUFFIX="${{ steps.curl-suffix-windows.outputs.suffix }}" + echo "hashes-windows=$(sha256sum windows-msvc-x64-static.zip windows-msvc-x64-dynamic.zip windows-msvc-x64-static-debug.zip windows-msvc-x64-dynamic-debug.zip windows-msvc-x64-static-curl${CURL_SUFFIX}.zip windows-msvc-x64-dynamic-curl${CURL_SUFFIX}.zip windows-msvc-x64-static-debug-curl${CURL_SUFFIX}.zip windows-msvc-x64-dynamic-debug-curl${CURL_SUFFIX}.zip | base64 -w0)" >> "$GITHUB_OUTPUT" - name: Upload Windows Build Artifacts if: runner.os == 'Windows' shell: bash run: | ls - gh release upload ${{ inputs.tag_name }} windows-msvc-x64-static.zip windows-msvc-x64-dynamic.zip windows-msvc-x64-static-debug.zip windows-msvc-x64-dynamic-debug.zip windows-msvc-x64-static-curl.zip windows-msvc-x64-dynamic-curl.zip windows-msvc-x64-static-debug-curl.zip windows-msvc-x64-dynamic-debug-curl.zip --clobber + CURL_SUFFIX="${{ steps.curl-suffix-windows.outputs.suffix }}" + gh release upload ${{ inputs.tag_name }} windows-msvc-x64-static.zip windows-msvc-x64-dynamic.zip windows-msvc-x64-static-debug.zip windows-msvc-x64-dynamic-debug.zip windows-msvc-x64-static-curl${CURL_SUFFIX}.zip windows-msvc-x64-dynamic-curl${CURL_SUFFIX}.zip windows-msvc-x64-static-debug-curl${CURL_SUFFIX}.zip windows-msvc-x64-dynamic-debug-curl${CURL_SUFFIX}.zip --clobber env: GH_TOKEN: ${{ inputs.github_token }} @@ -262,13 +288,24 @@ runs: type: 'zip' filename: 'mac-clang-x64-dynamic.zip' + - name: Determine CURL artifact suffix for server SDK + if: runner.os == 'macOS' + shell: bash + id: curl-suffix-macos + run: | + if [[ "${{ inputs.sdk_cmake_target }}" == "launchdarkly-cpp-server" ]]; then + echo "suffix=-experimental" >> $GITHUB_OUTPUT + else + echo "suffix=" >> $GITHUB_OUTPUT + fi + - name: Archive Release Mac - AppleClang/x64/Static/CURL if: runner.os == 'macOS' uses: thedoctor0/zip-release@0.7.1 with: path: 'build-static-curl/release' type: 'zip' - filename: 'mac-clang-x64-static-curl.zip' + filename: 'mac-clang-x64-static-curl${{ steps.curl-suffix-macos.outputs.suffix }}.zip' - name: Archive Release Mac - AppleClang/x64/Dynamic/CURL if: runner.os == 'macOS' @@ -276,20 +313,22 @@ runs: with: path: 'build-dynamic-curl/release' type: 'zip' - filename: 'mac-clang-x64-dynamic-curl.zip' + filename: 'mac-clang-x64-dynamic-curl${{ steps.curl-suffix-macos.outputs.suffix }}.zip' - name: Hash Mac Build Artifacts for provenance if: runner.os == 'macOS' shell: bash id: hash-macos run: | - echo "hashes-macos=$(shasum -a 256 mac-clang-x64-static.zip mac-clang-x64-dynamic.zip mac-clang-x64-static-curl.zip mac-clang-x64-dynamic-curl.zip | base64 -b 0)" >> "$GITHUB_OUTPUT" + CURL_SUFFIX="${{ steps.curl-suffix-macos.outputs.suffix }}" + echo "hashes-macos=$(shasum -a 256 mac-clang-x64-static.zip mac-clang-x64-dynamic.zip mac-clang-x64-static-curl${CURL_SUFFIX}.zip mac-clang-x64-dynamic-curl${CURL_SUFFIX}.zip | base64 -b 0)" >> "$GITHUB_OUTPUT" - name: Upload Mac Build Artifacts if: runner.os == 'macOS' shell: bash run: | ls - gh release upload ${{ inputs.tag_name }} mac-clang-x64-static.zip mac-clang-x64-dynamic.zip mac-clang-x64-static-curl.zip mac-clang-x64-dynamic-curl.zip --clobber + CURL_SUFFIX="${{ steps.curl-suffix-macos.outputs.suffix }}" + gh release upload ${{ inputs.tag_name }} mac-clang-x64-static.zip mac-clang-x64-dynamic.zip mac-clang-x64-static-curl${CURL_SUFFIX}.zip mac-clang-x64-dynamic-curl${CURL_SUFFIX}.zip --clobber env: GH_TOKEN: ${{ inputs.github_token }} diff --git a/README.md b/README.md index 15dc09893..d99e8dcb6 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,11 @@ By default, the SDK uses Boost.Beast/Foxy for HTTP networking. To use CURL inste cmake -B build -S . -DLD_CURL_NETWORKING=ON ``` +> [!WARNING] +> CURL support for the **server-side SDK** is currently **experimental**. It is subject to change and may not be fully tested for production use. + +Proxy support does not apply to the redis persistent store implementation for the server-side SDK. + #### CURL Requirements by Platform **Linux/macOS:** From f3783034f04e513341ed65c2f17e8c3eaca0f2a2 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 15 Oct 2025 10:41:04 -0700 Subject: [PATCH 56/90] Contract test conditionally supports proxies. --- contract-tests/client-contract-tests/CMakeLists.txt | 4 ++++ contract-tests/client-contract-tests/src/main.cpp | 3 +++ 2 files changed, 7 insertions(+) diff --git a/contract-tests/client-contract-tests/CMakeLists.txt b/contract-tests/client-contract-tests/CMakeLists.txt index a77e15c6c..562813459 100644 --- a/contract-tests/client-contract-tests/CMakeLists.txt +++ b/contract-tests/client-contract-tests/CMakeLists.txt @@ -8,6 +8,10 @@ project( LANGUAGES CXX ) +if (LD_CURL_NETWORKING) + target_compile_definitions(${LIBNAME} PUBLIC LD_CURL_NETWORKING) +endif() + include(${CMAKE_FILES}/json.cmake) add_executable(client-tests diff --git a/contract-tests/client-contract-tests/src/main.cpp b/contract-tests/client-contract-tests/src/main.cpp index 98a9435f8..9b2d4cada 100644 --- a/contract-tests/client-contract-tests/src/main.cpp +++ b/contract-tests/client-contract-tests/src/main.cpp @@ -47,7 +47,10 @@ int main(int argc, char* argv[]) { srv.add_capability("tls:skip-verify-peer"); srv.add_capability("tls:custom-ca"); srv.add_capability("client-prereq-events"); + // Proxies are supported only with CURL networking. +#ifdef LD_CURL_NETWORKING srv.add_capability("http-proxy"); +#endif net::signal_set signals{ioc, SIGINT, SIGTERM}; From e7622d0b7cab15c6b41c556e49608d870de3658f Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 15 Oct 2025 10:42:16 -0700 Subject: [PATCH 57/90] Target lib name. --- contract-tests/client-contract-tests/CMakeLists.txt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/contract-tests/client-contract-tests/CMakeLists.txt b/contract-tests/client-contract-tests/CMakeLists.txt index 562813459..4bffbe581 100644 --- a/contract-tests/client-contract-tests/CMakeLists.txt +++ b/contract-tests/client-contract-tests/CMakeLists.txt @@ -8,10 +8,6 @@ project( LANGUAGES CXX ) -if (LD_CURL_NETWORKING) - target_compile_definitions(${LIBNAME} PUBLIC LD_CURL_NETWORKING) -endif() - include(${CMAKE_FILES}/json.cmake) add_executable(client-tests @@ -31,4 +27,8 @@ target_link_libraries(client-tests PRIVATE contract-test-data-model ) +if (LD_CURL_NETWORKING) + target_compile_definitions(client-tests PUBLIC LD_CURL_NETWORKING) +endif() + target_include_directories(client-tests PUBLIC include) From 6b8d925045bb722edf286b07cc5a74a650d51076 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 15 Oct 2025 18:23:44 -0700 Subject: [PATCH 58/90] Start refactoring backoff. --- libs/server-sent-events/src/CMakeLists.txt | 6 +- libs/server-sent-events/src/backoff_timer.cpp | 125 ++++++++++++++++++ libs/server-sent-events/src/backoff_timer.hpp | 88 ++++++++++++ libs/server-sent-events/src/curl_client.cpp | 97 +++++++++----- libs/server-sent-events/src/curl_client.hpp | 73 ++++++---- 5 files changed, 328 insertions(+), 61 deletions(-) create mode 100644 libs/server-sent-events/src/backoff_timer.cpp create mode 100644 libs/server-sent-events/src/backoff_timer.hpp diff --git a/libs/server-sent-events/src/CMakeLists.txt b/libs/server-sent-events/src/CMakeLists.txt index 7c6e7d1d5..15d11ecbd 100644 --- a/libs/server-sent-events/src/CMakeLists.txt +++ b/libs/server-sent-events/src/CMakeLists.txt @@ -12,12 +12,14 @@ set(SSE_SOURCES event.cpp error.cpp backoff_detail.cpp - backoff.cpp) + backoff.cpp + curl_client.cpp + backoff_timer.cpp) if (LD_CURL_NETWORKING) message(STATUS "LaunchDarkly SSE: CURL networking enabled") find_package(CURL REQUIRED) - list(APPEND SSE_SOURCES curl_client.cpp) + list(APPEND SSE_SOURCES) endif() add_library(${LIBNAME} OBJECT ${SSE_SOURCES}) diff --git a/libs/server-sent-events/src/backoff_timer.cpp b/libs/server-sent-events/src/backoff_timer.cpp new file mode 100644 index 000000000..1952faec0 --- /dev/null +++ b/libs/server-sent-events/src/backoff_timer.cpp @@ -0,0 +1,125 @@ +#include "backoff_timer.hpp" + +#include + +namespace launchdarkly::sse { + +BackoffTimer::BackoffTimer(boost::asio::any_io_executor executor) + : executor_(std::move(executor)) { + std::thread t([this]() { timer_thread_func(); }); + t.detach(); +} + +BackoffTimer::~BackoffTimer() { + // Signal shutdown and wake up the timer thread + { + std::lock_guard lock(mutex_); + shutdown_ = true; + cancelled_ = true; + } + cv_.notify_one(); + + // Wait for the timer thread to complete + // if (timer_thread_.joinable()) { + // timer_thread_.join(); + // } +} + +void BackoffTimer::expires_after(std::chrono::milliseconds duration, + std::function callback) { + { + std::lock_guard lock(mutex_); + + // Cancel any existing timer + cancelled_ = timer_active_; + + // Set up the new timer + expiry_time_ = std::chrono::steady_clock::now() + duration; + callback_ = std::move(callback); + timer_active_ = true; + cancelled_ = false; + } + + // Wake up the timer thread + cv_.notify_one(); +} + +void BackoffTimer::cancel() { + { + std::lock_guard lock(mutex_); + if (timer_active_) { + cancelled_ = true; + } + } + + // Wake up the timer thread + cv_.notify_one(); +} + +boost::asio::any_io_executor BackoffTimer::get_executor() const { + return executor_; +} + +void BackoffTimer::timer_thread_func() { + while (true) { + std::function callback_to_invoke; + bool should_invoke = false; + bool was_cancelled = false; + + { + std::unique_lock lock(mutex_); + + // Wait for either: + // 1. A timer to be set (timer_active_ becomes true) + // 2. Shutdown signal + while (!timer_active_ && !shutdown_) { + cv_.wait(lock); + } + + // Check if we're shutting down + if (shutdown_) { + return; + } + + // Wait until the timer expires or is cancelled + while (timer_active_ && !cancelled_ && !shutdown_) { + auto now = std::chrono::steady_clock::now(); + if (now >= expiry_time_) { + // Timer expired + should_invoke = true; + was_cancelled = false; + callback_to_invoke = std::move(callback_); + timer_active_ = false; + break; + } + + // Wait until expiry time or until notified + cv_.wait_until(lock, expiry_time_); + } + + // Check if timer was cancelled + if (cancelled_ && timer_active_) { + should_invoke = true; + was_cancelled = true; + callback_to_invoke = std::move(callback_); + timer_active_ = false; + cancelled_ = false; + } + + // Check if we're shutting down + if (shutdown_) { + return; + } + } + + // Invoke callback outside of lock by posting to the executor + if (should_invoke && callback_to_invoke) { + boost::asio::post(executor_, [callback = std::move(callback_to_invoke), + was_cancelled]() { + callback(was_cancelled); + }); + } + } +} + +} // namespace launchdarkly::sse diff --git a/libs/server-sent-events/src/backoff_timer.hpp b/libs/server-sent-events/src/backoff_timer.hpp new file mode 100644 index 000000000..350fd513c --- /dev/null +++ b/libs/server-sent-events/src/backoff_timer.hpp @@ -0,0 +1,88 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace launchdarkly::sse { + +/** + * A thread-based timer that waits for a specified duration and then posts + * a callback to an ASIO executor. The timer can be cancelled, making it + * suitable for use in backoff scenarios where cleanup is required during + * destruction. + * + * The timer uses a dedicated thread for waiting (via condition_variable) + * and posts the callback to the provided ASIO executor when the timer expires. + * This avoids blocking the ASIO thread pool during backoff periods. + */ +class BackoffTimer { +public: + /** + * Construct a BackoffTimer with the given ASIO executor. + * @param executor The ASIO executor to post callbacks to when timer expires. + */ + explicit BackoffTimer(boost::asio::any_io_executor executor); + + /** + * Destructor. Cancels any pending timer and waits for the timer thread + * to complete. + */ + ~BackoffTimer(); + + // Non-copyable and non-movable + BackoffTimer(const BackoffTimer&) = delete; + BackoffTimer& operator=(const BackoffTimer&) = delete; + BackoffTimer(BackoffTimer&&) = delete; + BackoffTimer& operator=(BackoffTimer&&) = delete; + + /** + * Start an asynchronous wait. When the duration expires, the callback + * will be posted to the executor provided in the constructor. + * + * If a timer is already running, it will be cancelled before starting + * the new timer. + * + * @param duration The duration to wait before invoking the callback. + * @param callback The callback to invoke when the timer expires. + * The callback receives a boolean indicating whether + * the timer was cancelled (true) or expired normally (false). + */ + void expires_after(std::chrono::milliseconds duration, + std::function callback); + + /** + * Cancel any pending timer. If a timer is running, the callback will + * be invoked with cancelled=true. + */ + void cancel(); + + /** + * Get the executor used by this timer. + */ + boost::asio::any_io_executor get_executor() const; + +private: + void timer_thread_func(); + + boost::asio::any_io_executor executor_; + + std::mutex mutex_; + std::condition_variable cv_; + std::atomic shutdown_{false}; + std::atomic cancelled_{false}; + + std::chrono::steady_clock::time_point expiry_time_; + std::function callback_; + bool timer_active_{false}; + + std::thread timer_thread_; +}; + +} // namespace launchdarkly::sse diff --git a/libs/server-sent-events/src/curl_client.cpp b/libs/server-sent-events/src/curl_client.cpp index 95c38cc22..84194e005 100644 --- a/libs/server-sent-events/src/curl_client.cpp +++ b/libs/server-sent-events/src/curl_client.cpp @@ -62,32 +62,12 @@ CurlClient::CurlClient(boost::asio::any_io_executor executor, write_timeout, std::move(custom_ca_file), std::move(proxy_url), - skip_verify_peer, - [this](const std::string& message) { - boost::asio::post(backoff_timer_.get_executor(), [this, message]() { - async_backoff(message); - }); - }, - [this](const Event& event) { - boost::asio::post(backoff_timer_.get_executor(), [this, event]() { - event_receiver_(event); - }); - }, - [this](const Error& error) { - boost::asio::post(backoff_timer_.get_executor(), [this, error]() { - report_error(error); - }); - }, - [this]() { - boost::asio::post(backoff_timer_.get_executor(), [this]() { - backoff_.succeed(); - }); - }, [this](const std::string& message) { - log_message(message); - }); + skip_verify_peer); + std::cout << "Curl client created: " << host << std::endl; } CurlClient::~CurlClient() { + std::cout << "Curl client destructing: " << request_context_->url << std::endl; request_context_->shutdown(); backoff_timer_.cancel(); } @@ -97,12 +77,58 @@ void CurlClient::async_connect() { [self = shared_from_this()]() { self->do_run(); }); } -void CurlClient::do_run() const { +void CurlClient::do_run() { if (request_context_->is_shutting_down()) { return; } auto ctx = request_context_; + auto weak_self = weak_from_this(); + ctx->set_callbacks(Callbacks([weak_self, ctx](const std::string& message) { + std::cout << "Backoff " << ctx->url << " " << message << std::endl; + if (auto self = weak_self.lock()) { + boost::asio::post( + self->backoff_timer_. + get_executor(), + [self, message]() { + self->async_backoff(message); + }); + } + }, + [weak_self, ctx](const Event& event) { + std::cout << "Event " << ctx->url << " " << event.data() << std::endl; + if (auto self = weak_self.lock()) { + boost::asio::post( + self->backoff_timer_. + get_executor(), + [self, event]() { + self->event_receiver_(event); + }); + } + }, + [weak_self, ctx](const Error& error) { + std::cout << "Error " << ctx->url << " " << error << std::endl; + if (const auto self = weak_self.lock()) { + // report_error does an asio post. + self->report_error(error); + } + }, + [weak_self, ctx]() { + std::cout << "Reset backoff" << ctx->url << std::endl; + if (const auto self = weak_self.lock()) { + boost::asio::post( + self->backoff_timer_. + get_executor(), + [self]() { + self->backoff_.succeed(); + }); + } + }, [weak_self, ctx](const std::string& message) { + std::cout << "Log " << ctx->url << " " << message << std::endl; + if (const auto self = weak_self.lock()) { + self->log_message(message); + } + })); // Start request in a separate thread since CURL blocks // Capture only raw 'this' pointer, not shared_ptr std::thread t( @@ -122,19 +148,21 @@ void CurlClient::async_backoff(std::string const& reason) { log_message(msg.str()); - backoff_timer_.expires_after(backoff_.delay()); - backoff_timer_.async_wait([self = shared_from_this()]( - const boost::system::error_code& ec) { - self->on_backoff(ec); + // auto weak_self = weak_from_this(); + backoff_timer_.expires_after(backoff_.delay(), + [this](bool cancelled) { + if (request_context_->is_shutting_down()) { + return; + } + // if (const auto self = weak_self.lock()) { + on_backoff(cancelled); + // } }); } -void CurlClient::on_backoff(const boost::system::error_code& ec) const { - { - if (ec == boost::asio::error::operation_aborted || request_context_-> - is_shutting_down()) { - return; - } +void CurlClient::on_backoff(bool cancelled) { + if (cancelled || request_context_->is_shutting_down()) { + return; } do_run(); } @@ -492,6 +520,7 @@ size_t CurlClient::HeaderCallback(const char* buffer, } void CurlClient::PerformRequest(std::shared_ptr context) { + std::cout << "CurlClient::PerformRequest: " << context->url << std::endl; if (context->is_shutting_down()) { return; } diff --git a/libs/server-sent-events/src/curl_client.hpp b/libs/server-sent-events/src/curl_client.hpp index ffcfd10f4..5529fa48d 100644 --- a/libs/server-sent-events/src/curl_client.hpp +++ b/libs/server-sent-events/src/curl_client.hpp @@ -4,11 +4,11 @@ #include #include "backoff.hpp" +#include "backoff_timer.hpp" #include "parser.hpp" #include #include -#include #include #include @@ -38,18 +38,36 @@ namespace net = boost::asio; */ class CurlClient final : public Client, public std::enable_shared_from_this { + struct Callbacks { + std::function do_backoff; + std::function on_receive; + std::function on_error; + std::function reset_backoff; + std::function log_message; + + Callbacks( + std::function do_backoff, + std::function on_receive, + std::function on_error, + std::function reset_backoff, + std::function log_message + ) : + do_backoff(std::move(do_backoff)), + on_receive(std::move(on_receive)), + on_error(std::move(on_error)), + reset_backoff(std::move(reset_backoff)), + log_message(std::move(log_message)) { + } + }; + class RequestContext { // Only items used by both the curl thread and the executor/main // thread need to be mutex protected. std::mutex mutex_; std::atomic shutting_down_; std::atomic curl_socket_; - std::function do_backoff_; - std::function on_receive_; - std::function on_error_; - std::function reset_backoff_; - std::function log_message_; // End mutex protected items. + std::optional callbacks_; public: // SSE parser state @@ -79,7 +97,9 @@ class CurlClient final : public Client, if (shutting_down_) { return; } - do_backoff_(message); + if (callbacks_) { + callbacks_->do_backoff(message); + } } void error(const Error& error) { @@ -87,7 +107,9 @@ class CurlClient final : public Client, if (shutting_down_) { return; } - on_error_(error); + if (callbacks_) { + callbacks_->on_error(error); + } } void receive(const Event& event) { @@ -95,7 +117,9 @@ class CurlClient final : public Client, if (shutting_down_) { return; } - on_receive_(event); + if (callbacks_) { + callbacks_->on_receive(event); + } } void reset_backoff() { @@ -103,7 +127,9 @@ class CurlClient final : public Client, if (shutting_down_) { return; } - reset_backoff_(); + if (callbacks_) { + callbacks_->reset_backoff(); + } } void log_message(const std::string& message) { @@ -111,7 +137,14 @@ class CurlClient final : public Client, if (shutting_down_) { return; } - log_message_(message); + if (callbacks_) { + callbacks_->log_message(message); + } + } + + void set_callbacks(Callbacks callbacks) { + std::lock_guard lock(mutex_); + callbacks_ = std::move(callbacks); } bool is_shutting_down() { @@ -143,19 +176,9 @@ class CurlClient final : public Client, std::optional write_timeout, std::optional custom_ca_file, std::optional proxy_url, - bool skip_verify_peer, - std::function doBackoff, - std::function onReceive, - std::function onError, - std::function resetBackoff, - std::function log_message + bool skip_verify_peer ) : shutting_down_(false), curl_socket_(CURL_SOCKET_BAD), - do_backoff_(std::move(doBackoff)), - on_receive_(std::move(onReceive)), - on_error_(std::move(onError)), - reset_backoff_(std::move(resetBackoff)), - log_message_(std::move(log_message)), buffered_line(std::nullopt), begin_CR(false), last_download_amount(0), @@ -195,10 +218,10 @@ class CurlClient final : public Client, void async_shutdown(std::function completion) override; private: - void do_run() const; + void do_run(); void do_shutdown(const std::function& completion); void async_backoff(std::string const& reason); - void on_backoff(const boost::system::error_code& ec) const; + void on_backoff(bool cancelled); static void PerformRequest( std::shared_ptr context); @@ -239,7 +262,7 @@ class CurlClient final : public Client, Builder::ErrorCallback errors_; bool use_https_; - boost::asio::steady_timer backoff_timer_; + BackoffTimer backoff_timer_; Backoff backoff_; }; From 27a498ff32d2097a4a6a1698ae151ed13a2efa19 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 16 Oct 2025 09:24:56 -0700 Subject: [PATCH 59/90] Initial --- .../network/curl_multi_manager.hpp | 107 +++++++++ .../launchdarkly/network/curl_requester.hpp | 8 +- libs/internal/src/CMakeLists.txt | 2 +- .../src/network/curl_multi_manager.cpp | 220 ++++++++++++++++++ libs/internal/src/network/curl_requester.cpp | 146 ++++++------ libs/server-sent-events/src/CMakeLists.txt | 6 +- libs/server-sent-events/src/backoff_timer.cpp | 125 ---------- libs/server-sent-events/src/backoff_timer.hpp | 88 ------- libs/server-sent-events/src/curl_client.cpp | 173 +++++++------- libs/server-sent-events/src/curl_client.hpp | 11 +- .../src/curl_multi_manager.cpp | 220 ++++++++++++++++++ .../src/curl_multi_manager.hpp | 107 +++++++++ 12 files changed, 824 insertions(+), 389 deletions(-) create mode 100644 libs/internal/include/launchdarkly/network/curl_multi_manager.hpp create mode 100644 libs/internal/src/network/curl_multi_manager.cpp delete mode 100644 libs/server-sent-events/src/backoff_timer.cpp delete mode 100644 libs/server-sent-events/src/backoff_timer.hpp create mode 100644 libs/server-sent-events/src/curl_multi_manager.cpp create mode 100644 libs/server-sent-events/src/curl_multi_manager.hpp diff --git a/libs/internal/include/launchdarkly/network/curl_multi_manager.hpp b/libs/internal/include/launchdarkly/network/curl_multi_manager.hpp new file mode 100644 index 000000000..840ef8383 --- /dev/null +++ b/libs/internal/include/launchdarkly/network/curl_multi_manager.hpp @@ -0,0 +1,107 @@ +#pragma once + +#ifdef LD_CURL_NETWORKING + +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace launchdarkly::network { + +/** + * Manages CURL multi interface integrated with ASIO event loop. + * + * This class provides non-blocking HTTP operations by integrating CURL's + * multi interface with Boost.ASIO. Instead of blocking threads, CURL notifies + * us via callbacks when sockets need attention, and we use ASIO to monitor + * those sockets asynchronously. + * + * Key features: + * - Non-blocking I/O using curl_multi_socket_action + * - Socket monitoring via ASIO stream_descriptor + * - Timer integration with ASIO steady_timer + * - Thread-safe operation on ASIO executor + */ +class CurlMultiManager : public std::enable_shared_from_this { +public: + /** + * Callback invoked when an easy handle completes (success or error). + * Parameters: CURL* easy handle, CURLcode result + */ + using CompletionCallback = std::function; + + /** + * Create a CurlMultiManager on the given executor. + * @param executor The ASIO executor to run operations on + */ + static std::shared_ptr create( + boost::asio::any_io_executor executor); + + ~CurlMultiManager(); + + // Non-copyable and non-movable + CurlMultiManager(const CurlMultiManager&) = delete; + CurlMultiManager& operator=(const CurlMultiManager&) = delete; + CurlMultiManager(CurlMultiManager&&) = delete; + CurlMultiManager& operator=(CurlMultiManager&&) = delete; + + /** + * Add an easy handle to be managed. + * @param easy The CURL easy handle (must be configured) + * @param callback Called when the transfer completes + */ + void add_handle(CURL* easy, CompletionCallback callback); + + /** + * Remove an easy handle from management. + * @param easy The CURL easy handle to remove + */ + void remove_handle(CURL* easy); + +private: + explicit CurlMultiManager(boost::asio::any_io_executor executor); + + // Called by CURL when socket state changes + static int socket_callback(CURL* easy, curl_socket_t s, int what, + void* userp, void* socketp); + + // Called by CURL when timer should be set + static int timer_callback(CURLM* multi, long timeout_ms, void* userp); + + // Handle socket events + void handle_socket_action(curl_socket_t s, int event_bitmask); + + // Handle timer expiry + void handle_timeout(); + + // Check for completed transfers + void check_multi_info(); + + // Per-socket data + struct SocketInfo { + curl_socket_t sockfd; + std::unique_ptr descriptor; + int action{0}; // CURL_POLL_IN, CURL_POLL_OUT, etc. + }; + + void start_socket_monitor(SocketInfo* socket_info, int action); + void stop_socket_monitor(SocketInfo* socket_info); + + boost::asio::any_io_executor executor_; + CURLM* multi_handle_; + boost::asio::steady_timer timer_; + + std::mutex mutex_; + std::map callbacks_; + int still_running_{0}; +}; + +} // namespace launchdarkly::network + +#endif // LD_CURL_NETWORKING diff --git a/libs/internal/include/launchdarkly/network/curl_requester.hpp b/libs/internal/include/launchdarkly/network/curl_requester.hpp index 49bf73e39..94603d097 100644 --- a/libs/internal/include/launchdarkly/network/curl_requester.hpp +++ b/libs/internal/include/launchdarkly/network/curl_requester.hpp @@ -4,6 +4,7 @@ #include "http_requester.hpp" #include "asio_requester.hpp" +#include "curl_multi_manager.hpp" #include #include @@ -20,11 +21,14 @@ class CurlRequester { void Request(HttpRequest request, std::function cb) const; private: - static void PerformRequestStatic(net::any_io_executor ctx, TlsOptions const& tls_options, - const HttpRequest& request, std::function cb); + static void PerformRequestWithMulti(std::shared_ptr multi_manager, + TlsOptions const& tls_options, + const HttpRequest& request, + std::function cb); net::any_io_executor ctx_; TlsOptions tls_options_; + std::shared_ptr multi_manager_; }; } // namespace launchdarkly::network diff --git a/libs/internal/src/CMakeLists.txt b/libs/internal/src/CMakeLists.txt index 88d6209dc..67910175b 100644 --- a/libs/internal/src/CMakeLists.txt +++ b/libs/internal/src/CMakeLists.txt @@ -50,7 +50,7 @@ set(INTERNAL_SOURCES if (LD_CURL_NETWORKING) message(STATUS "LaunchDarkly Internal: CURL networking enabled") find_package(CURL REQUIRED) - list(APPEND INTERNAL_SOURCES network/curl_requester.cpp) + list(APPEND INTERNAL_SOURCES network/curl_requester.cpp network/curl_multi_manager.cpp) endif() add_library(${LIBNAME} OBJECT ${INTERNAL_SOURCES}) diff --git a/libs/internal/src/network/curl_multi_manager.cpp b/libs/internal/src/network/curl_multi_manager.cpp new file mode 100644 index 000000000..8436a0758 --- /dev/null +++ b/libs/internal/src/network/curl_multi_manager.cpp @@ -0,0 +1,220 @@ +#ifdef LD_CURL_NETWORKING + +#include "launchdarkly/network/curl_multi_manager.hpp" + +#include + +#include + +namespace launchdarkly::network { + +std::shared_ptr CurlMultiManager::create( + boost::asio::any_io_executor executor) { + // Can't use make_shared because constructor is private + return std::shared_ptr( + new CurlMultiManager(std::move(executor))); +} + +CurlMultiManager::CurlMultiManager(boost::asio::any_io_executor executor) + : executor_(std::move(executor)), + multi_handle_(curl_multi_init()), + timer_(executor_) { + + if (!multi_handle_) { + throw std::runtime_error("Failed to initialize CURL multi handle"); + } + + // Set callbacks for socket and timer notifications + curl_multi_setopt(multi_handle_, CURLMOPT_SOCKETFUNCTION, socket_callback); + curl_multi_setopt(multi_handle_, CURLMOPT_SOCKETDATA, this); + curl_multi_setopt(multi_handle_, CURLMOPT_TIMERFUNCTION, timer_callback); + curl_multi_setopt(multi_handle_, CURLMOPT_TIMERDATA, this); +} + +CurlMultiManager::~CurlMultiManager() { + if (multi_handle_) { + curl_multi_cleanup(multi_handle_); + } +} + +void CurlMultiManager::add_handle(CURL* easy, CompletionCallback callback) { + { + std::lock_guard lock(mutex_); + callbacks_[easy] = std::move(callback); + } + + CURLMcode rc = curl_multi_add_handle(multi_handle_, easy); + if (rc != CURLM_OK) { + std::lock_guard lock(mutex_); + callbacks_.erase(easy); + std::cerr << "Failed to add handle to multi: " + << curl_multi_strerror(rc) << std::endl; + } +} + +void CurlMultiManager::remove_handle(CURL* easy) { + curl_multi_remove_handle(multi_handle_, easy); + + std::lock_guard lock(mutex_); + callbacks_.erase(easy); +} + +int CurlMultiManager::socket_callback(CURL* easy, curl_socket_t s, int what, + void* userp, void* socketp) { + auto* manager = static_cast(userp); + auto* socket_info = static_cast(socketp); + + if (what == CURL_POLL_REMOVE) { + if (socket_info) { + manager->stop_socket_monitor(socket_info); + curl_multi_assign(manager->multi_handle_, s, nullptr); + delete socket_info; + } + } else { + if (!socket_info) { + // New socket + socket_info = new SocketInfo{s, nullptr, 0}; + curl_multi_assign(manager->multi_handle_, s, socket_info); + } + + manager->start_socket_monitor(socket_info, what); + } + + return 0; +} + +int CurlMultiManager::timer_callback(CURLM* multi, long timeout_ms, void* userp) { + auto* manager = static_cast(userp); + + // Cancel any existing timer + manager->timer_.cancel(); + + if (timeout_ms > 0) { + // Set new timer + manager->timer_.expires_after(std::chrono::milliseconds(timeout_ms)); + manager->timer_.async_wait([weak_self = manager->weak_from_this()]( + const boost::system::error_code& ec) { + if (!ec) { + if (auto self = weak_self.lock()) { + self->handle_timeout(); + } + } + }); + } else if (timeout_ms == 0) { + // Call socket_action immediately + boost::asio::post(manager->executor_, [weak_self = manager->weak_from_this()]() { + if (auto self = weak_self.lock()) { + self->handle_timeout(); + } + }); + } + // If timeout_ms < 0, no timeout (delete timer) + + return 0; +} + +void CurlMultiManager::handle_socket_action(curl_socket_t s, int event_bitmask) { + int running_handles = 0; + CURLMcode rc = curl_multi_socket_action(multi_handle_, s, event_bitmask, + &running_handles); + + if (rc != CURLM_OK) { + std::cerr << "curl_multi_socket_action failed: " + << curl_multi_strerror(rc) << std::endl; + } + + check_multi_info(); + + if (running_handles != still_running_) { + still_running_ = running_handles; + } +} + +void CurlMultiManager::handle_timeout() { + handle_socket_action(CURL_SOCKET_TIMEOUT, 0); +} + +void CurlMultiManager::check_multi_info() { + int msgs_in_queue; + CURLMsg* msg; + + while ((msg = curl_multi_info_read(multi_handle_, &msgs_in_queue))) { + if (msg->msg == CURLMSG_DONE) { + CURL* easy = msg->easy_handle; + CURLcode result = msg->data.result; + + CompletionCallback callback; + { + std::lock_guard lock(mutex_); + auto it = callbacks_.find(easy); + if (it != callbacks_.end()) { + callback = std::move(it->second); + callbacks_.erase(it); + } + } + + // Remove from multi handle + curl_multi_remove_handle(multi_handle_, easy); + + // Invoke completion callback + if (callback) { + boost::asio::post(executor_, [callback = std::move(callback), + easy, result]() { + callback(easy, result); + }); + } + } + } +} + +void CurlMultiManager::start_socket_monitor(SocketInfo* socket_info, int action) { + if (!socket_info->descriptor) { + // Create descriptor for this socket + socket_info->descriptor = std::make_unique< + boost::asio::posix::stream_descriptor>(executor_); + socket_info->descriptor->assign(socket_info->sockfd); + } + + socket_info->action = action; + + auto weak_self = weak_from_this(); + curl_socket_t sockfd = socket_info->sockfd; + + // Monitor for read events + if (action & CURL_POLL_IN) { + socket_info->descriptor->async_wait( + boost::asio::posix::stream_descriptor::wait_read, + [weak_self, sockfd](const boost::system::error_code& ec) { + if (!ec) { + if (auto self = weak_self.lock()) { + self->handle_socket_action(sockfd, CURL_CSELECT_IN); + } + } + }); + } + + // Monitor for write events + if (action & CURL_POLL_OUT) { + socket_info->descriptor->async_wait( + boost::asio::posix::stream_descriptor::wait_write, + [weak_self, sockfd](const boost::system::error_code& ec) { + if (!ec) { + if (auto self = weak_self.lock()) { + self->handle_socket_action(sockfd, CURL_CSELECT_OUT); + } + } + }); + } +} + +void CurlMultiManager::stop_socket_monitor(SocketInfo* socket_info) { + if (socket_info->descriptor) { + socket_info->descriptor->cancel(); + socket_info->descriptor->release(); + socket_info->descriptor.reset(); + } +} + +} // namespace launchdarkly::network + +#endif // LD_CURL_NETWORKING diff --git a/libs/internal/src/network/curl_requester.cpp b/libs/internal/src/network/curl_requester.cpp index 9b8b94858..ded42063d 100644 --- a/libs/internal/src/network/curl_requester.cpp +++ b/libs/internal/src/network/curl_requester.cpp @@ -67,44 +67,65 @@ static size_t HeaderCallback(const char* buffer, const size_t size, const size_t } CurlRequester::CurlRequester(net::any_io_executor ctx, TlsOptions const& tls_options) - : ctx_(std::move(ctx)), tls_options_(tls_options) { + : ctx_(std::move(ctx)), + tls_options_(tls_options), + multi_manager_(CurlMultiManager::create(ctx_)) { curl_global_init(CURL_GLOBAL_DEFAULT); } void CurlRequester::Request(HttpRequest request, std::function cb) const { - // Post the request to the executor to perform it asynchronously - // Copy ctx_ and tls_options_ to avoid capturing 'this' and causing use-after-free - // if the CurlRequester is destroyed while the operation is in flight. - auto ctx = ctx_; + // Copy necessary data to avoid capturing 'this' + auto multi_manager = multi_manager_; auto tls_options = tls_options_; - boost::asio::post(ctx, [ctx, tls_options, request = std::move(request), cb = std::move(cb)]() mutable { - PerformRequestStatic(ctx, tls_options, std::move(request), std::move(cb)); + + boost::asio::post(ctx_, [multi_manager, tls_options, request = std::move(request), cb = std::move(cb)]() mutable { + PerformRequestWithMulti(multi_manager, tls_options, std::move(request), std::move(cb)); }); } -void CurlRequester::PerformRequestStatic(net::any_io_executor ctx, TlsOptions const& tls_options, - const HttpRequest& request, std::function cb) { +void CurlRequester::PerformRequestWithMulti(std::shared_ptr multi_manager, + TlsOptions const& tls_options, + const HttpRequest& request, + std::function cb) { // Validate request if (!request.Valid()) { - boost::asio::post(ctx, [cb = std::move(cb)]() { - cb(HttpResult(kErrorMalformedRequest)); - }); + cb(HttpResult(kErrorMalformedRequest)); return; } CURL* curl = curl_easy_init(); if (!curl) { - boost::asio::post(ctx, [cb = std::move(cb)]() { - cb(HttpResult(kErrorCurlInit)); - }); + cb(HttpResult(kErrorCurlInit)); return; } - // Use a unique_ptr to manage the cleanup of our curl instance. - std::unique_ptr curl_guard(curl, curl_easy_cleanup); + // Create context to hold data for this request + // This will be cleaned up in the completion callback + struct RequestContext { + CURL* curl; + curl_slist* headers; + std::string url; + std::string body; // Keep body alive + std::string response_body; + HttpResult::HeadersType response_headers; + std::function callback; + + ~RequestContext() { + if (headers) { + curl_slist_free_all(headers); + } + if (curl) { + curl_easy_cleanup(curl); + } + } + }; + + auto ctx = std::make_shared(); + ctx->curl = curl; + ctx->headers = nullptr; + ctx->callback = std::move(cb); // Helper macro to check curl_easy_setopt return values - // Note: This macro captures ctx and cb by reference for error reporting #define CURL_SETOPT_CHECK(handle, option, parameter) \ do { \ CURLcode code = curl_easy_setopt(handle, option, parameter); \ @@ -112,21 +133,16 @@ void CurlRequester::PerformRequestStatic(net::any_io_executor ctx, TlsOptions co std::string error_message = kErrorCurlPrefix; \ error_message += "curl_easy_setopt failed for " #option ": "; \ error_message += curl_easy_strerror(code); \ - boost::asio::post(ctx, [cb = std::move(cb), error_message = std::move(error_message)]() { \ - cb(HttpResult(error_message)); \ - }); \ + ctx->callback(HttpResult(error_message)); \ return; \ } \ } while(0) - std::string response_body; - HttpResult::HeadersType response_headers; - // Store URL to keep it alive for the duration of the request - std::string url = request.Url(); + ctx->url = request.Url(); // Set URL - CURL_SETOPT_CHECK(curl, CURLOPT_URL, url.c_str()); + CURL_SETOPT_CHECK(curl, CURLOPT_URL, ctx->url.c_str()); // Set HTTP method if (request.Method() == HttpMethod::kPost) { @@ -145,34 +161,28 @@ void CurlRequester::PerformRequestStatic(net::any_io_executor ctx, TlsOptions co // Set request body if present if (request.Body().has_value()) { - const std::string& body = request.Body().value(); - CURL_SETOPT_CHECK(curl, CURLOPT_POSTFIELDS, body.c_str()); - CURL_SETOPT_CHECK(curl, CURLOPT_POSTFIELDSIZE, body.size()); + ctx->body = request.Body().value(); + CURL_SETOPT_CHECK(curl, CURLOPT_POSTFIELDS, ctx->body.c_str()); + CURL_SETOPT_CHECK(curl, CURLOPT_POSTFIELDSIZE, ctx->body.size()); } // Set headers - struct curl_slist* headers = nullptr; auto const& base_headers = request.Properties().BaseHeaders(); for (auto const& [key, value] : base_headers) { std::string header = key + kHeaderSeparator + value; - // The first call to curl_slist_append will create the list. - // Subsequent calls will return either the same pointer, or null. - // In the case they return null, we need to clean up any previous result - // and abort the operation. - const auto appendResult = curl_slist_append(headers, header.c_str()); + const auto appendResult = curl_slist_append(ctx->headers, header.c_str()); if (!appendResult) { - if (headers) { - curl_slist_free_all(headers); + if (ctx->headers) { + curl_slist_free_all(ctx->headers); + ctx->headers = nullptr; } - boost::asio::post(ctx, [cb = std::move(cb)]() { - cb(HttpResult(kErrorHeaderAppend)); - }); + ctx->callback(HttpResult(kErrorHeaderAppend)); return; } - headers = appendResult; + ctx->headers = appendResult; } - if (headers) { - CURL_SETOPT_CHECK(curl, CURLOPT_HTTPHEADER, headers); + if (ctx->headers) { + CURL_SETOPT_CHECK(curl, CURLOPT_HTTPHEADER, ctx->headers); } // Set timeouts with millisecond precision @@ -211,43 +221,37 @@ void CurlRequester::PerformRequestStatic(net::any_io_executor ctx, TlsOptions co // Set callbacks CURL_SETOPT_CHECK(curl, CURLOPT_WRITEFUNCTION, WriteCallback); - CURL_SETOPT_CHECK(curl, CURLOPT_WRITEDATA, &response_body); + CURL_SETOPT_CHECK(curl, CURLOPT_WRITEDATA, &ctx->response_body); CURL_SETOPT_CHECK(curl, CURLOPT_HEADERFUNCTION, HeaderCallback); - CURL_SETOPT_CHECK(curl, CURLOPT_HEADERDATA, &response_headers); + CURL_SETOPT_CHECK(curl, CURLOPT_HEADERDATA, &ctx->response_headers); // Follow redirects CURL_SETOPT_CHECK(curl, CURLOPT_FOLLOWLOCATION, 1L); CURL_SETOPT_CHECK(curl, CURLOPT_MAXREDIRS, 20L); - // Perform the request - CURLcode res = curl_easy_perform(curl); + #undef CURL_SETOPT_CHECK - // Cleanup headers - if (headers) { - curl_slist_free_all(headers); - } + // Add handle to multi manager for async processing + multi_manager->add_handle(curl, [ctx](CURL* easy, CURLcode result) { + // This callback runs on the executor when the request completes - // Check for errors - if (res != CURLE_OK) { - std::string error_message = kErrorCurlPrefix; - error_message += curl_easy_strerror(res); - boost::asio::post(ctx, [cb = std::move(cb), error_message = std::move(error_message)]() { - cb(HttpResult(error_message)); - }); - return; - } + // Check for errors + if (result != CURLE_OK) { + std::string error_message = kErrorCurlPrefix; + error_message += curl_easy_strerror(result); + ctx->callback(HttpResult(error_message)); + return; + } + + // Get HTTP response code + long response_code = 0; + curl_easy_getinfo(easy, CURLINFO_RESPONSE_CODE, &response_code); - // Get HTTP response code - long response_code = 0; - curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &response_code); - - // Post the success result back to the executor - boost::asio::post(ctx, [cb = std::move(cb), response_code, - response_body = std::move(response_body), - response_headers = std::move(response_headers)]() mutable { - cb(HttpResult(static_cast(response_code), - std::move(response_body), - std::move(response_headers))); + // Invoke the user's callback with the result + ctx->callback(HttpResult( + static_cast(response_code), + std::move(ctx->response_body), + std::move(ctx->response_headers))); }); } diff --git a/libs/server-sent-events/src/CMakeLists.txt b/libs/server-sent-events/src/CMakeLists.txt index 15d11ecbd..d11385baf 100644 --- a/libs/server-sent-events/src/CMakeLists.txt +++ b/libs/server-sent-events/src/CMakeLists.txt @@ -12,14 +12,12 @@ set(SSE_SOURCES event.cpp error.cpp backoff_detail.cpp - backoff.cpp - curl_client.cpp - backoff_timer.cpp) + backoff.cpp) if (LD_CURL_NETWORKING) message(STATUS "LaunchDarkly SSE: CURL networking enabled") find_package(CURL REQUIRED) - list(APPEND SSE_SOURCES) + list(APPEND SSE_SOURCES curl_client.cpp curl_multi_manager.cpp) endif() add_library(${LIBNAME} OBJECT ${SSE_SOURCES}) diff --git a/libs/server-sent-events/src/backoff_timer.cpp b/libs/server-sent-events/src/backoff_timer.cpp deleted file mode 100644 index 1952faec0..000000000 --- a/libs/server-sent-events/src/backoff_timer.cpp +++ /dev/null @@ -1,125 +0,0 @@ -#include "backoff_timer.hpp" - -#include - -namespace launchdarkly::sse { - -BackoffTimer::BackoffTimer(boost::asio::any_io_executor executor) - : executor_(std::move(executor)) { - std::thread t([this]() { timer_thread_func(); }); - t.detach(); -} - -BackoffTimer::~BackoffTimer() { - // Signal shutdown and wake up the timer thread - { - std::lock_guard lock(mutex_); - shutdown_ = true; - cancelled_ = true; - } - cv_.notify_one(); - - // Wait for the timer thread to complete - // if (timer_thread_.joinable()) { - // timer_thread_.join(); - // } -} - -void BackoffTimer::expires_after(std::chrono::milliseconds duration, - std::function callback) { - { - std::lock_guard lock(mutex_); - - // Cancel any existing timer - cancelled_ = timer_active_; - - // Set up the new timer - expiry_time_ = std::chrono::steady_clock::now() + duration; - callback_ = std::move(callback); - timer_active_ = true; - cancelled_ = false; - } - - // Wake up the timer thread - cv_.notify_one(); -} - -void BackoffTimer::cancel() { - { - std::lock_guard lock(mutex_); - if (timer_active_) { - cancelled_ = true; - } - } - - // Wake up the timer thread - cv_.notify_one(); -} - -boost::asio::any_io_executor BackoffTimer::get_executor() const { - return executor_; -} - -void BackoffTimer::timer_thread_func() { - while (true) { - std::function callback_to_invoke; - bool should_invoke = false; - bool was_cancelled = false; - - { - std::unique_lock lock(mutex_); - - // Wait for either: - // 1. A timer to be set (timer_active_ becomes true) - // 2. Shutdown signal - while (!timer_active_ && !shutdown_) { - cv_.wait(lock); - } - - // Check if we're shutting down - if (shutdown_) { - return; - } - - // Wait until the timer expires or is cancelled - while (timer_active_ && !cancelled_ && !shutdown_) { - auto now = std::chrono::steady_clock::now(); - if (now >= expiry_time_) { - // Timer expired - should_invoke = true; - was_cancelled = false; - callback_to_invoke = std::move(callback_); - timer_active_ = false; - break; - } - - // Wait until expiry time or until notified - cv_.wait_until(lock, expiry_time_); - } - - // Check if timer was cancelled - if (cancelled_ && timer_active_) { - should_invoke = true; - was_cancelled = true; - callback_to_invoke = std::move(callback_); - timer_active_ = false; - cancelled_ = false; - } - - // Check if we're shutting down - if (shutdown_) { - return; - } - } - - // Invoke callback outside of lock by posting to the executor - if (should_invoke && callback_to_invoke) { - boost::asio::post(executor_, [callback = std::move(callback_to_invoke), - was_cancelled]() { - callback(was_cancelled); - }); - } - } -} - -} // namespace launchdarkly::sse diff --git a/libs/server-sent-events/src/backoff_timer.hpp b/libs/server-sent-events/src/backoff_timer.hpp deleted file mode 100644 index 350fd513c..000000000 --- a/libs/server-sent-events/src/backoff_timer.hpp +++ /dev/null @@ -1,88 +0,0 @@ -#pragma once - -#include - -#include -#include -#include -#include -#include -#include -#include - -namespace launchdarkly::sse { - -/** - * A thread-based timer that waits for a specified duration and then posts - * a callback to an ASIO executor. The timer can be cancelled, making it - * suitable for use in backoff scenarios where cleanup is required during - * destruction. - * - * The timer uses a dedicated thread for waiting (via condition_variable) - * and posts the callback to the provided ASIO executor when the timer expires. - * This avoids blocking the ASIO thread pool during backoff periods. - */ -class BackoffTimer { -public: - /** - * Construct a BackoffTimer with the given ASIO executor. - * @param executor The ASIO executor to post callbacks to when timer expires. - */ - explicit BackoffTimer(boost::asio::any_io_executor executor); - - /** - * Destructor. Cancels any pending timer and waits for the timer thread - * to complete. - */ - ~BackoffTimer(); - - // Non-copyable and non-movable - BackoffTimer(const BackoffTimer&) = delete; - BackoffTimer& operator=(const BackoffTimer&) = delete; - BackoffTimer(BackoffTimer&&) = delete; - BackoffTimer& operator=(BackoffTimer&&) = delete; - - /** - * Start an asynchronous wait. When the duration expires, the callback - * will be posted to the executor provided in the constructor. - * - * If a timer is already running, it will be cancelled before starting - * the new timer. - * - * @param duration The duration to wait before invoking the callback. - * @param callback The callback to invoke when the timer expires. - * The callback receives a boolean indicating whether - * the timer was cancelled (true) or expired normally (false). - */ - void expires_after(std::chrono::milliseconds duration, - std::function callback); - - /** - * Cancel any pending timer. If a timer is running, the callback will - * be invoked with cancelled=true. - */ - void cancel(); - - /** - * Get the executor used by this timer. - */ - boost::asio::any_io_executor get_executor() const; - -private: - void timer_thread_func(); - - boost::asio::any_io_executor executor_; - - std::mutex mutex_; - std::condition_variable cv_; - std::atomic shutdown_{false}; - std::atomic cancelled_{false}; - - std::chrono::steady_clock::time_point expiry_time_; - std::function callback_; - bool timer_active_{false}; - - std::thread timer_thread_; -}; - -} // namespace launchdarkly::sse diff --git a/libs/server-sent-events/src/curl_client.cpp b/libs/server-sent-events/src/curl_client.cpp index 84194e005..d1d59a791 100644 --- a/libs/server-sent-events/src/curl_client.cpp +++ b/libs/server-sent-events/src/curl_client.cpp @@ -51,7 +51,8 @@ CurlClient::CurlClient(boost::asio::any_io_executor executor, logger_(std::move(logger)), errors_(std::move(errors)), use_https_(use_https), - backoff_timer_(std::move(executor)), + backoff_timer_(executor), + multi_manager_(CurlMultiManager::create(executor)), backoff_(initial_reconnect_delay.value_or(kDefaultInitialReconnectDelay), kDefaultMaxBackoffDelay) { request_context_ = std::make_shared( @@ -129,12 +130,8 @@ void CurlClient::do_run() { self->log_message(message); } })); - // Start request in a separate thread since CURL blocks - // Capture only raw 'this' pointer, not shared_ptr - std::thread t( - [ctx]() { PerformRequest(ctx); }); - - t.detach(); + // Start request using CURL multi (non-blocking) + PerformRequestWithMulti(multi_manager_, ctx); } void CurlClient::async_backoff(std::string const& reason) { @@ -148,20 +145,17 @@ void CurlClient::async_backoff(std::string const& reason) { log_message(msg.str()); - // auto weak_self = weak_from_this(); - backoff_timer_.expires_after(backoff_.delay(), - [this](bool cancelled) { - if (request_context_->is_shutting_down()) { - return; - } - // if (const auto self = weak_self.lock()) { - on_backoff(cancelled); - // } - }); + auto weak_self = weak_from_this(); + backoff_timer_.expires_after(backoff_.delay()); + backoff_timer_.async_wait([weak_self](const boost::system::error_code& ec) { + if (auto self = weak_self.lock()) { + self->on_backoff(ec); + } + }); } -void CurlClient::on_backoff(bool cancelled) { - if (cancelled || request_context_->is_shutting_down()) { +void CurlClient::on_backoff(boost::system::error_code const& ec) { + if (ec || request_context_->is_shutting_down()) { return; } do_run(); @@ -519,8 +513,11 @@ size_t CurlClient::HeaderCallback(const char* buffer, return total_size; } -void CurlClient::PerformRequest(std::shared_ptr context) { - std::cout << "CurlClient::PerformRequest: " << context->url << std::endl; +void CurlClient::PerformRequestWithMulti( + std::shared_ptr multi_manager, + std::shared_ptr context) { + + std::cout << "CurlClient::PerformRequestWithMulti: " << context->url << std::endl; if (context->is_shutting_down()) { return; } @@ -554,104 +551,92 @@ void CurlClient::PerformRequest(std::shared_ptr context) { return; } - // Perform the request - CURLcode res = curl_easy_perform(curl); + // Add handle to multi manager for async processing + multi_manager->add_handle(curl, [context, headers](CURL* easy, CURLcode res) { + // Get response code + long response_code = 0; + curl_easy_getinfo(easy, CURLINFO_RESPONSE_CODE, &response_code); - // Get response code - long response_code = 0; - curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &response_code); - - // Free headers before cleanup - if (headers) { - curl_slist_free_all(headers); - } + // Free headers before cleanup + if (headers) { + curl_slist_free_all(headers); + } - // Handle HTTP status codes - auto status = static_cast(response_code); - auto status_class = http::to_status_class(status); + // Handle HTTP status codes + auto status = static_cast(response_code); + auto status_class = http::to_status_class(status); - curl_easy_cleanup(curl); + curl_easy_cleanup(easy); - if (context->is_shutting_down()) { - return; - } - - if (status_class == http::status_class::redirection) { - // The internal CURL handling of redirects failed. - // This situation is likely the result of a missing redirect header - // or empty header. - context->error(errors::NotRedirectable{}); - } - - // Handle result - if (res != CURLE_OK) { if (context->is_shutting_down()) { return; } - // Check if the error was due to progress callback aborting (read timeout) - if (res == CURLE_ABORTED_BY_CALLBACK && context-> - read_timeout) { - context->error(errors::ReadTimeout{ - context->read_timeout - }); - context->backoff("aborting read of response body (timeout)"); - } else { - std::string error_msg = "CURL error: " + std::string( - curl_easy_strerror(res)); - context->backoff(error_msg); + if (status_class == http::status_class::redirection) { + // The internal CURL handling of redirects failed. + // This situation is likely the result of a missing redirect header + // or empty header. + context->error(errors::NotRedirectable{}); + return; } - return; - } + // Handle result + if (res != CURLE_OK) { + if (context->is_shutting_down()) { + return; + } - if (status_class == http::status_class::successful) { - if (status == http::status::no_content) { - if (!context->is_shutting_down()) { - context->error(errors::UnrecoverableClientError{ - http::status::no_content}); + // Check if the error was due to progress callback aborting (read timeout) + if (res == CURLE_ABORTED_BY_CALLBACK && context->read_timeout) { + context->error(errors::ReadTimeout{context->read_timeout}); + context->backoff("aborting read of response body (timeout)"); + } else { + std::string error_msg = "CURL error: " + std::string(curl_easy_strerror(res)); + context->backoff(error_msg); } + return; } - if (!context->is_shutting_down()) { - // log_message("connected"); - } - context->reset_backoff(); - // Connection ended normally, reconnect - if (!context->is_shutting_down()) { - context->backoff("connection closed normally"); + + if (status_class == http::status_class::successful) { + if (status == http::status::no_content) { + if (!context->is_shutting_down()) { + context->error(errors::UnrecoverableClientError{http::status::no_content}); + } + return; + } + context->reset_backoff(); + // Connection ended normally, reconnect + if (!context->is_shutting_down()) { + context->backoff("connection closed normally"); + } + return; } - return; - } - if (status_class == http::status_class::client_error) { - if (!context->is_shutting_down()) { - bool recoverable = (status == http::status::bad_request || - status == http::status::request_timeout || - status == http::status::too_many_requests); - - if (recoverable) { - std::stringstream ss; - ss << "HTTP status " << static_cast(status); - context->backoff(ss.str()); - } else { - context->error(errors::UnrecoverableClientError{ - status}); + if (status_class == http::status_class::client_error) { + if (!context->is_shutting_down()) { + bool recoverable = (status == http::status::bad_request || + status == http::status::request_timeout || + status == http::status::too_many_requests); + + if (recoverable) { + std::stringstream ss; + ss << "HTTP status " << static_cast(status); + context->backoff(ss.str()); + } else { + context->error(errors::UnrecoverableClientError{status}); + } } + return; } - return; - } - { // Server error or other - backoff and retry if (!context->is_shutting_down()) { std::stringstream ss; ss << "HTTP status " << static_cast(status); context->backoff(ss.str()); } - } - - // Keepalive will be cleared by guard's destructor when function exits + }); } void CurlClient::async_shutdown(std::function completion) { diff --git a/libs/server-sent-events/src/curl_client.hpp b/libs/server-sent-events/src/curl_client.hpp index 5529fa48d..f1a5ce1a7 100644 --- a/libs/server-sent-events/src/curl_client.hpp +++ b/libs/server-sent-events/src/curl_client.hpp @@ -4,11 +4,12 @@ #include #include "backoff.hpp" -#include "backoff_timer.hpp" +#include "curl_multi_manager.hpp" #include "parser.hpp" #include #include +#include #include #include @@ -221,8 +222,9 @@ class CurlClient final : public Client, void do_run(); void do_shutdown(const std::function& completion); void async_backoff(std::string const& reason); - void on_backoff(bool cancelled); - static void PerformRequest( + void on_backoff(boost::system::error_code const& ec); + static void PerformRequestWithMulti( + std::shared_ptr multi_manager, std::shared_ptr context); static size_t WriteCallback(const char* data, @@ -262,7 +264,8 @@ class CurlClient final : public Client, Builder::ErrorCallback errors_; bool use_https_; - BackoffTimer backoff_timer_; + boost::asio::steady_timer backoff_timer_; + std::shared_ptr multi_manager_; Backoff backoff_; }; diff --git a/libs/server-sent-events/src/curl_multi_manager.cpp b/libs/server-sent-events/src/curl_multi_manager.cpp new file mode 100644 index 000000000..fe8d63355 --- /dev/null +++ b/libs/server-sent-events/src/curl_multi_manager.cpp @@ -0,0 +1,220 @@ +#ifdef LD_CURL_NETWORKING + +#include "curl_multi_manager.hpp" + +#include + +#include + +namespace launchdarkly::sse { + +std::shared_ptr CurlMultiManager::create( + boost::asio::any_io_executor executor) { + // Can't use make_shared because constructor is private + return std::shared_ptr( + new CurlMultiManager(std::move(executor))); +} + +CurlMultiManager::CurlMultiManager(boost::asio::any_io_executor executor) + : executor_(std::move(executor)), + multi_handle_(curl_multi_init()), + timer_(executor_) { + + if (!multi_handle_) { + throw std::runtime_error("Failed to initialize CURL multi handle"); + } + + // Set callbacks for socket and timer notifications + curl_multi_setopt(multi_handle_, CURLMOPT_SOCKETFUNCTION, socket_callback); + curl_multi_setopt(multi_handle_, CURLMOPT_SOCKETDATA, this); + curl_multi_setopt(multi_handle_, CURLMOPT_TIMERFUNCTION, timer_callback); + curl_multi_setopt(multi_handle_, CURLMOPT_TIMERDATA, this); +} + +CurlMultiManager::~CurlMultiManager() { + if (multi_handle_) { + curl_multi_cleanup(multi_handle_); + } +} + +void CurlMultiManager::add_handle(CURL* easy, CompletionCallback callback) { + { + std::lock_guard lock(mutex_); + callbacks_[easy] = std::move(callback); + } + + CURLMcode rc = curl_multi_add_handle(multi_handle_, easy); + if (rc != CURLM_OK) { + std::lock_guard lock(mutex_); + callbacks_.erase(easy); + std::cerr << "Failed to add handle to multi: " + << curl_multi_strerror(rc) << std::endl; + } +} + +void CurlMultiManager::remove_handle(CURL* easy) { + curl_multi_remove_handle(multi_handle_, easy); + + std::lock_guard lock(mutex_); + callbacks_.erase(easy); +} + +int CurlMultiManager::socket_callback(CURL* easy, curl_socket_t s, int what, + void* userp, void* socketp) { + auto* manager = static_cast(userp); + auto* socket_info = static_cast(socketp); + + if (what == CURL_POLL_REMOVE) { + if (socket_info) { + manager->stop_socket_monitor(socket_info); + curl_multi_assign(manager->multi_handle_, s, nullptr); + delete socket_info; + } + } else { + if (!socket_info) { + // New socket + socket_info = new SocketInfo{s, nullptr, 0}; + curl_multi_assign(manager->multi_handle_, s, socket_info); + } + + manager->start_socket_monitor(socket_info, what); + } + + return 0; +} + +int CurlMultiManager::timer_callback(CURLM* multi, long timeout_ms, void* userp) { + auto* manager = static_cast(userp); + + // Cancel any existing timer + manager->timer_.cancel(); + + if (timeout_ms > 0) { + // Set new timer + manager->timer_.expires_after(std::chrono::milliseconds(timeout_ms)); + manager->timer_.async_wait([weak_self = manager->weak_from_this()]( + const boost::system::error_code& ec) { + if (!ec) { + if (auto self = weak_self.lock()) { + self->handle_timeout(); + } + } + }); + } else if (timeout_ms == 0) { + // Call socket_action immediately + boost::asio::post(manager->executor_, [weak_self = manager->weak_from_this()]() { + if (auto self = weak_self.lock()) { + self->handle_timeout(); + } + }); + } + // If timeout_ms < 0, no timeout (delete timer) + + return 0; +} + +void CurlMultiManager::handle_socket_action(curl_socket_t s, int event_bitmask) { + int running_handles = 0; + CURLMcode rc = curl_multi_socket_action(multi_handle_, s, event_bitmask, + &running_handles); + + if (rc != CURLM_OK) { + std::cerr << "curl_multi_socket_action failed: " + << curl_multi_strerror(rc) << std::endl; + } + + check_multi_info(); + + if (running_handles != still_running_) { + still_running_ = running_handles; + } +} + +void CurlMultiManager::handle_timeout() { + handle_socket_action(CURL_SOCKET_TIMEOUT, 0); +} + +void CurlMultiManager::check_multi_info() { + int msgs_in_queue; + CURLMsg* msg; + + while ((msg = curl_multi_info_read(multi_handle_, &msgs_in_queue))) { + if (msg->msg == CURLMSG_DONE) { + CURL* easy = msg->easy_handle; + CURLcode result = msg->data.result; + + CompletionCallback callback; + { + std::lock_guard lock(mutex_); + auto it = callbacks_.find(easy); + if (it != callbacks_.end()) { + callback = std::move(it->second); + callbacks_.erase(it); + } + } + + // Remove from multi handle + curl_multi_remove_handle(multi_handle_, easy); + + // Invoke completion callback + if (callback) { + boost::asio::post(executor_, [callback = std::move(callback), + easy, result]() { + callback(easy, result); + }); + } + } + } +} + +void CurlMultiManager::start_socket_monitor(SocketInfo* socket_info, int action) { + if (!socket_info->descriptor) { + // Create descriptor for this socket + socket_info->descriptor = std::make_unique< + boost::asio::posix::stream_descriptor>(executor_); + socket_info->descriptor->assign(socket_info->sockfd); + } + + socket_info->action = action; + + auto weak_self = weak_from_this(); + curl_socket_t sockfd = socket_info->sockfd; + + // Monitor for read events + if (action & CURL_POLL_IN) { + socket_info->descriptor->async_wait( + boost::asio::posix::stream_descriptor::wait_read, + [weak_self, sockfd](const boost::system::error_code& ec) { + if (!ec) { + if (auto self = weak_self.lock()) { + self->handle_socket_action(sockfd, CURL_CSELECT_IN); + } + } + }); + } + + // Monitor for write events + if (action & CURL_POLL_OUT) { + socket_info->descriptor->async_wait( + boost::asio::posix::stream_descriptor::wait_write, + [weak_self, sockfd](const boost::system::error_code& ec) { + if (!ec) { + if (auto self = weak_self.lock()) { + self->handle_socket_action(sockfd, CURL_CSELECT_OUT); + } + } + }); + } +} + +void CurlMultiManager::stop_socket_monitor(SocketInfo* socket_info) { + if (socket_info->descriptor) { + socket_info->descriptor->cancel(); + socket_info->descriptor->release(); + socket_info->descriptor.reset(); + } +} + +} // namespace launchdarkly::sse + +#endif // LD_CURL_NETWORKING diff --git a/libs/server-sent-events/src/curl_multi_manager.hpp b/libs/server-sent-events/src/curl_multi_manager.hpp new file mode 100644 index 000000000..e987ef0a9 --- /dev/null +++ b/libs/server-sent-events/src/curl_multi_manager.hpp @@ -0,0 +1,107 @@ +#pragma once + +#ifdef LD_CURL_NETWORKING + +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace launchdarkly::sse { + +/** + * Manages CURL multi interface integrated with ASIO event loop. + * + * This class provides non-blocking HTTP operations by integrating CURL's + * multi interface with Boost.ASIO. Instead of blocking threads, CURL notifies + * us via callbacks when sockets need attention, and we use ASIO to monitor + * those sockets asynchronously. + * + * Key features: + * - Non-blocking I/O using curl_multi_socket_action + * - Socket monitoring via ASIO stream_descriptor + * - Timer integration with ASIO steady_timer + * - Thread-safe operation on ASIO executor + */ +class CurlMultiManager : public std::enable_shared_from_this { +public: + /** + * Callback invoked when an easy handle completes (success or error). + * Parameters: CURL* easy handle, CURLcode result + */ + using CompletionCallback = std::function; + + /** + * Create a CurlMultiManager on the given executor. + * @param executor The ASIO executor to run operations on + */ + static std::shared_ptr create( + boost::asio::any_io_executor executor); + + ~CurlMultiManager(); + + // Non-copyable and non-movable + CurlMultiManager(const CurlMultiManager&) = delete; + CurlMultiManager& operator=(const CurlMultiManager&) = delete; + CurlMultiManager(CurlMultiManager&&) = delete; + CurlMultiManager& operator=(CurlMultiManager&&) = delete; + + /** + * Add an easy handle to be managed. + * @param easy The CURL easy handle (must be configured) + * @param callback Called when the transfer completes + */ + void add_handle(CURL* easy, CompletionCallback callback); + + /** + * Remove an easy handle from management. + * @param easy The CURL easy handle to remove + */ + void remove_handle(CURL* easy); + +private: + explicit CurlMultiManager(boost::asio::any_io_executor executor); + + // Called by CURL when socket state changes + static int socket_callback(CURL* easy, curl_socket_t s, int what, + void* userp, void* socketp); + + // Called by CURL when timer should be set + static int timer_callback(CURLM* multi, long timeout_ms, void* userp); + + // Handle socket events + void handle_socket_action(curl_socket_t s, int event_bitmask); + + // Handle timer expiry + void handle_timeout(); + + // Check for completed transfers + void check_multi_info(); + + // Per-socket data + struct SocketInfo { + curl_socket_t sockfd; + std::unique_ptr descriptor; + int action{0}; // CURL_POLL_IN, CURL_POLL_OUT, etc. + }; + + void start_socket_monitor(SocketInfo* socket_info, int action); + void stop_socket_monitor(SocketInfo* socket_info); + + boost::asio::any_io_executor executor_; + CURLM* multi_handle_; + boost::asio::steady_timer timer_; + + std::mutex mutex_; + std::map callbacks_; + int still_running_{0}; +}; + +} // namespace launchdarkly::sse + +#endif // LD_CURL_NETWORKING From 09ba09d9471f42339ab30d70c9a48b6fe2184aad Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 16 Oct 2025 13:32:42 -0700 Subject: [PATCH 60/90] Contract tests passing with multi support. --- .../network/curl_multi_manager.hpp | 6 +- .../src/network/curl_multi_manager.cpp | 126 ++++++++++++++-- libs/internal/src/network/curl_requester.cpp | 28 ++-- libs/server-sent-events/src/curl_client.cpp | 96 ++++++------ .../src/curl_multi_manager.cpp | 140 +++++++++++++++--- .../src/curl_multi_manager.hpp | 12 +- 6 files changed, 311 insertions(+), 97 deletions(-) diff --git a/libs/internal/include/launchdarkly/network/curl_multi_manager.hpp b/libs/internal/include/launchdarkly/network/curl_multi_manager.hpp index 840ef8383..445eb8d69 100644 --- a/libs/internal/include/launchdarkly/network/curl_multi_manager.hpp +++ b/libs/internal/include/launchdarkly/network/curl_multi_manager.hpp @@ -54,9 +54,10 @@ class CurlMultiManager : public std::enable_shared_from_this { /** * Add an easy handle to be managed. * @param easy The CURL easy handle (must be configured) + * @param headers The curl_slist headers (will be freed automatically) * @param callback Called when the transfer completes */ - void add_handle(CURL* easy, CompletionCallback callback); + void add_handle(CURL* easy, curl_slist* headers, CompletionCallback callback); /** * Remove an easy handle from management. @@ -86,7 +87,7 @@ class CurlMultiManager : public std::enable_shared_from_this { // Per-socket data struct SocketInfo { curl_socket_t sockfd; - std::unique_ptr descriptor; + std::shared_ptr descriptor; int action{0}; // CURL_POLL_IN, CURL_POLL_OUT, etc. }; @@ -99,6 +100,7 @@ class CurlMultiManager : public std::enable_shared_from_this { std::mutex mutex_; std::map callbacks_; + std::map headers_; int still_running_{0}; }; diff --git a/libs/internal/src/network/curl_multi_manager.cpp b/libs/internal/src/network/curl_multi_manager.cpp index 8436a0758..b12440f5f 100644 --- a/libs/internal/src/network/curl_multi_manager.cpp +++ b/libs/internal/src/network/curl_multi_manager.cpp @@ -33,20 +33,54 @@ CurlMultiManager::CurlMultiManager(boost::asio::any_io_executor executor) CurlMultiManager::~CurlMultiManager() { if (multi_handle_) { + // Extract and clear pending handles, callbacks, and headers + std::map pending_callbacks; + std::map pending_headers; + { + std::lock_guard lock(mutex_); + pending_callbacks = std::move(callbacks_); + pending_headers = std::move(headers_); + callbacks_.clear(); + headers_.clear(); + } + + // Remove handles from multi and cleanup resources + // Do NOT invoke callbacks as they may access destroyed objects + for (auto& [easy, callback] : pending_callbacks) { + curl_multi_remove_handle(multi_handle_, easy); + + // Free headers if they exist for this handle + auto header_it = pending_headers.find(easy); + if (header_it != pending_headers.end() && header_it->second) { + curl_slist_free_all(header_it->second); + } + + curl_easy_cleanup(easy); + } + curl_multi_cleanup(multi_handle_); } } -void CurlMultiManager::add_handle(CURL* easy, CompletionCallback callback) { +void CurlMultiManager::add_handle(CURL* easy, curl_slist* headers, CompletionCallback callback) { { std::lock_guard lock(mutex_); callbacks_[easy] = std::move(callback); + headers_[easy] = headers; } CURLMcode rc = curl_multi_add_handle(multi_handle_, easy); if (rc != CURLM_OK) { std::lock_guard lock(mutex_); callbacks_.erase(easy); + + // Free headers on error + auto header_it = headers_.find(easy); + if (header_it != headers_.end() && header_it->second) { + curl_slist_free_all(header_it->second); + } + headers_.erase(easy); + std::cerr << "Failed to add handle to multi: " << curl_multi_strerror(rc) << std::endl; } @@ -57,6 +91,13 @@ void CurlMultiManager::remove_handle(CURL* easy) { std::lock_guard lock(mutex_); callbacks_.erase(easy); + + // Free headers if they exist + auto header_it = headers_.find(easy); + if (header_it != headers_.end() && header_it->second) { + curl_slist_free_all(header_it->second); + } + headers_.erase(easy); } int CurlMultiManager::socket_callback(CURL* easy, curl_socket_t s, int what, @@ -144,6 +185,7 @@ void CurlMultiManager::check_multi_info() { CURLcode result = msg->data.result; CompletionCallback callback; + curl_slist* headers = nullptr; { std::lock_guard lock(mutex_); auto it = callbacks_.find(easy); @@ -151,11 +193,23 @@ void CurlMultiManager::check_multi_info() { callback = std::move(it->second); callbacks_.erase(it); } + + // Get and remove headers + auto header_it = headers_.find(easy); + if (header_it != headers_.end()) { + headers = header_it->second; + headers_.erase(header_it); + } } // Remove from multi handle curl_multi_remove_handle(multi_handle_, easy); + // Free headers + if (headers) { + curl_slist_free_all(headers); + } + // Invoke completion callback if (callback) { boost::asio::post(executor_, [callback = std::move(callback), @@ -170,7 +224,7 @@ void CurlMultiManager::check_multi_info() { void CurlMultiManager::start_socket_monitor(SocketInfo* socket_info, int action) { if (!socket_info->descriptor) { // Create descriptor for this socket - socket_info->descriptor = std::make_unique< + socket_info->descriptor = std::make_shared< boost::asio::posix::stream_descriptor>(executor_); socket_info->descriptor->assign(socket_info->sockfd); } @@ -182,28 +236,72 @@ void CurlMultiManager::start_socket_monitor(SocketInfo* socket_info, int action) // Monitor for read events if (action & CURL_POLL_IN) { - socket_info->descriptor->async_wait( - boost::asio::posix::stream_descriptor::wait_read, - [weak_self, sockfd](const boost::system::error_code& ec) { - if (!ec) { + // Use weak_ptr to safely detect when descriptor is deleted + std::weak_ptr weak_descriptor = socket_info->descriptor; + + // Use shared_ptr for recursive lambda + auto read_handler = std::make_shared>(); + *read_handler = [weak_self, sockfd, weak_descriptor, read_handler]() { + // Check if manager and descriptor are still valid + auto self = weak_self.lock(); + auto descriptor = weak_descriptor.lock(); + if (!self || !descriptor) { + return; + } + + descriptor->async_wait( + boost::asio::posix::stream_descriptor::wait_read, + [weak_self, sockfd, weak_descriptor, read_handler](const boost::system::error_code& ec) { + // If operation was canceled or had an error, don't re-register + if (ec) { + return; + } + if (auto self = weak_self.lock()) { self->handle_socket_action(sockfd, CURL_CSELECT_IN); + + // Always try to re-register for continuous monitoring + // The validity check at the top of read_handler will stop it if needed + (*read_handler)(); // Recursive call } - } - }); + }); + }; + (*read_handler)(); // Initial call } // Monitor for write events if (action & CURL_POLL_OUT) { - socket_info->descriptor->async_wait( - boost::asio::posix::stream_descriptor::wait_write, - [weak_self, sockfd](const boost::system::error_code& ec) { - if (!ec) { + // Use weak_ptr to safely detect when descriptor is deleted + std::weak_ptr weak_descriptor = socket_info->descriptor; + + // Use shared_ptr for recursive lambda + auto write_handler = std::make_shared>(); + *write_handler = [weak_self, sockfd, weak_descriptor, write_handler]() { + // Check if manager and descriptor are still valid + auto self = weak_self.lock(); + auto descriptor = weak_descriptor.lock(); + if (!self || !descriptor) { + return; + } + + descriptor->async_wait( + boost::asio::posix::stream_descriptor::wait_write, + [weak_self, sockfd, weak_descriptor, write_handler](const boost::system::error_code& ec) { + // If operation was canceled or had an error, don't re-register + if (ec) { + return; + } + if (auto self = weak_self.lock()) { self->handle_socket_action(sockfd, CURL_CSELECT_OUT); + + // Always try to re-register for continuous monitoring + // The validity check at the top of write_handler will stop it if needed + (*write_handler)(); // Recursive call } - } - }); + }); + }; + (*write_handler)(); // Initial call } } diff --git a/libs/internal/src/network/curl_requester.cpp b/libs/internal/src/network/curl_requester.cpp index ded42063d..bba78242c 100644 --- a/libs/internal/src/network/curl_requester.cpp +++ b/libs/internal/src/network/curl_requester.cpp @@ -103,7 +103,6 @@ void CurlRequester::PerformRequestWithMulti(std::shared_ptr mu // This will be cleaned up in the completion callback struct RequestContext { CURL* curl; - curl_slist* headers; std::string url; std::string body; // Keep body alive std::string response_body; @@ -111,9 +110,7 @@ void CurlRequester::PerformRequestWithMulti(std::shared_ptr mu std::function callback; ~RequestContext() { - if (headers) { - curl_slist_free_all(headers); - } + // Headers are managed by CurlMultiManager if (curl) { curl_easy_cleanup(curl); } @@ -122,9 +119,11 @@ void CurlRequester::PerformRequestWithMulti(std::shared_ptr mu auto ctx = std::make_shared(); ctx->curl = curl; - ctx->headers = nullptr; ctx->callback = std::move(cb); + // Headers will be managed by CurlMultiManager + curl_slist* headers = nullptr; + // Helper macro to check curl_easy_setopt return values #define CURL_SETOPT_CHECK(handle, option, parameter) \ do { \ @@ -133,6 +132,9 @@ void CurlRequester::PerformRequestWithMulti(std::shared_ptr mu std::string error_message = kErrorCurlPrefix; \ error_message += "curl_easy_setopt failed for " #option ": "; \ error_message += curl_easy_strerror(code); \ + if (headers) { \ + curl_slist_free_all(headers); \ + } \ ctx->callback(HttpResult(error_message)); \ return; \ } \ @@ -170,19 +172,18 @@ void CurlRequester::PerformRequestWithMulti(std::shared_ptr mu auto const& base_headers = request.Properties().BaseHeaders(); for (auto const& [key, value] : base_headers) { std::string header = key + kHeaderSeparator + value; - const auto appendResult = curl_slist_append(ctx->headers, header.c_str()); + const auto appendResult = curl_slist_append(headers, header.c_str()); if (!appendResult) { - if (ctx->headers) { - curl_slist_free_all(ctx->headers); - ctx->headers = nullptr; + if (headers) { + curl_slist_free_all(headers); } ctx->callback(HttpResult(kErrorHeaderAppend)); return; } - ctx->headers = appendResult; + headers = appendResult; } - if (ctx->headers) { - CURL_SETOPT_CHECK(curl, CURLOPT_HTTPHEADER, ctx->headers); + if (headers) { + CURL_SETOPT_CHECK(curl, CURLOPT_HTTPHEADER, headers); } // Set timeouts with millisecond precision @@ -232,7 +233,8 @@ void CurlRequester::PerformRequestWithMulti(std::shared_ptr mu #undef CURL_SETOPT_CHECK // Add handle to multi manager for async processing - multi_manager->add_handle(curl, [ctx](CURL* easy, CURLcode result) { + // Headers will be freed automatically by CurlMultiManager + multi_manager->add_handle(curl, headers, [ctx](CURL* easy, CURLcode result) { // This callback runs on the executor when the request completes // Check for errors diff --git a/libs/server-sent-events/src/curl_client.cpp b/libs/server-sent-events/src/curl_client.cpp index d1d59a791..343db9d87 100644 --- a/libs/server-sent-events/src/curl_client.cpp +++ b/libs/server-sent-events/src/curl_client.cpp @@ -64,11 +64,9 @@ CurlClient::CurlClient(boost::asio::any_io_executor executor, std::move(custom_ca_file), std::move(proxy_url), skip_verify_peer); - std::cout << "Curl client created: " << host << std::endl; } CurlClient::~CurlClient() { - std::cout << "Curl client destructing: " << request_context_->url << std::endl; request_context_->shutdown(); backoff_timer_.cancel(); } @@ -85,49 +83,55 @@ void CurlClient::do_run() { auto ctx = request_context_; auto weak_self = weak_from_this(); - ctx->set_callbacks(Callbacks([weak_self, ctx](const std::string& message) { - std::cout << "Backoff " << ctx->url << " " << message << std::endl; - if (auto self = weak_self.lock()) { - boost::asio::post( - self->backoff_timer_. - get_executor(), - [self, message]() { - self->async_backoff(message); - }); + std::weak_ptr weak_ctx = ctx; + ctx->set_callbacks(Callbacks([weak_self, weak_ctx](const std::string& message) { + if (auto ctx = weak_ctx.lock()) { + if (auto self = weak_self.lock()) { + boost::asio::post( + self->backoff_timer_. + get_executor(), + [self, message]() { + self->async_backoff(message); + }); + } } }, - [weak_self, ctx](const Event& event) { - std::cout << "Event " << ctx->url << " " << event.data() << std::endl; - if (auto self = weak_self.lock()) { - boost::asio::post( - self->backoff_timer_. - get_executor(), - [self, event]() { - self->event_receiver_(event); - }); + [weak_self, weak_ctx](const Event& event) { + if (auto ctx = weak_ctx.lock()) { + if (auto self = weak_self.lock()) { + boost::asio::post( + self->backoff_timer_. + get_executor(), + [self, event]() { + self->event_receiver_(event); + }); + } } }, - [weak_self, ctx](const Error& error) { - std::cout << "Error " << ctx->url << " " << error << std::endl; - if (const auto self = weak_self.lock()) { - // report_error does an asio post. - self->report_error(error); + [weak_self, weak_ctx](const Error& error) { + if (auto ctx = weak_ctx.lock()) { + if (const auto self = weak_self.lock()) { + // report_error does an asio post. + self->report_error(error); + } } }, - [weak_self, ctx]() { - std::cout << "Reset backoff" << ctx->url << std::endl; - if (const auto self = weak_self.lock()) { - boost::asio::post( - self->backoff_timer_. - get_executor(), - [self]() { - self->backoff_.succeed(); - }); + [weak_self, weak_ctx]() { + if (auto ctx = weak_ctx.lock()) { + if (const auto self = weak_self.lock()) { + boost::asio::post( + self->backoff_timer_. + get_executor(), + [self]() { + self->backoff_.succeed(); + }); + } } - }, [weak_self, ctx](const std::string& message) { - std::cout << "Log " << ctx->url << " " << message << std::endl; - if (const auto self = weak_self.lock()) { - self->log_message(message); + }, [weak_self, weak_ctx](const std::string& message) { + if (auto ctx = weak_ctx.lock()) { + if (const auto self = weak_self.lock()) { + self->log_message(message); + } } })); // Start request using CURL multi (non-blocking) @@ -517,7 +521,6 @@ void CurlClient::PerformRequestWithMulti( std::shared_ptr multi_manager, std::shared_ptr context) { - std::cout << "CurlClient::PerformRequestWithMulti: " << context->url << std::endl; if (context->is_shutting_down()) { return; } @@ -552,16 +555,19 @@ void CurlClient::PerformRequestWithMulti( } // Add handle to multi manager for async processing - multi_manager->add_handle(curl, [context, headers](CURL* easy, CURLcode res) { + // Headers will be freed automatically by CurlMultiManager + std::weak_ptr weak_context = context; + multi_manager->add_handle(curl, headers, [weak_context](CURL* easy, CURLcode res) { + auto context = weak_context.lock(); + if (!context) { + curl_easy_cleanup(easy); + return; + } + // Get response code long response_code = 0; curl_easy_getinfo(easy, CURLINFO_RESPONSE_CODE, &response_code); - // Free headers before cleanup - if (headers) { - curl_slist_free_all(headers); - } - // Handle HTTP status codes auto status = static_cast(response_code); auto status_class = http::to_status_class(status); diff --git a/libs/server-sent-events/src/curl_multi_manager.cpp b/libs/server-sent-events/src/curl_multi_manager.cpp index fe8d63355..b85273be9 100644 --- a/libs/server-sent-events/src/curl_multi_manager.cpp +++ b/libs/server-sent-events/src/curl_multi_manager.cpp @@ -33,20 +33,54 @@ CurlMultiManager::CurlMultiManager(boost::asio::any_io_executor executor) CurlMultiManager::~CurlMultiManager() { if (multi_handle_) { + // Extract and clear pending handles, callbacks, and headers + std::map pending_callbacks; + std::map pending_headers; + { + std::lock_guard lock(mutex_); + pending_callbacks = std::move(callbacks_); + pending_headers = std::move(headers_); + callbacks_.clear(); + headers_.clear(); + } + + // Remove handles from multi and cleanup resources + // Do NOT invoke callbacks as they may access destroyed objects + for (auto& [easy, callback] : pending_callbacks) { + curl_multi_remove_handle(multi_handle_, easy); + + // Free headers if they exist for this handle + auto header_it = pending_headers.find(easy); + if (header_it != pending_headers.end() && header_it->second) { + curl_slist_free_all(header_it->second); + } + + curl_easy_cleanup(easy); + } + curl_multi_cleanup(multi_handle_); } } -void CurlMultiManager::add_handle(CURL* easy, CompletionCallback callback) { +void CurlMultiManager::add_handle(CURL* easy, curl_slist* headers, CompletionCallback callback) { { std::lock_guard lock(mutex_); callbacks_[easy] = std::move(callback); + headers_[easy] = headers; } CURLMcode rc = curl_multi_add_handle(multi_handle_, easy); if (rc != CURLM_OK) { std::lock_guard lock(mutex_); callbacks_.erase(easy); + + // Free headers on error + auto header_it = headers_.find(easy); + if (header_it != headers_.end() && header_it->second) { + curl_slist_free_all(header_it->second); + } + headers_.erase(easy); + std::cerr << "Failed to add handle to multi: " << curl_multi_strerror(rc) << std::endl; } @@ -57,6 +91,13 @@ void CurlMultiManager::remove_handle(CURL* easy) { std::lock_guard lock(mutex_); callbacks_.erase(easy); + + // Free headers if they exist + auto header_it = headers_.find(easy); + if (header_it != headers_.end() && header_it->second) { + curl_slist_free_all(header_it->second); + } + headers_.erase(easy); } int CurlMultiManager::socket_callback(CURL* easy, curl_socket_t s, int what, @@ -144,6 +185,7 @@ void CurlMultiManager::check_multi_info() { CURLcode result = msg->data.result; CompletionCallback callback; + curl_slist* headers = nullptr; { std::lock_guard lock(mutex_); auto it = callbacks_.find(easy); @@ -151,11 +193,23 @@ void CurlMultiManager::check_multi_info() { callback = std::move(it->second); callbacks_.erase(it); } + + // Get and remove headers + auto header_it = headers_.find(easy); + if (header_it != headers_.end()) { + headers = header_it->second; + headers_.erase(header_it); + } } // Remove from multi handle curl_multi_remove_handle(multi_handle_, easy); + // Free headers + if (headers) { + curl_slist_free_all(headers); + } + // Invoke completion callback if (callback) { boost::asio::post(executor_, [callback = std::move(callback), @@ -170,41 +224,85 @@ void CurlMultiManager::check_multi_info() { void CurlMultiManager::start_socket_monitor(SocketInfo* socket_info, int action) { if (!socket_info->descriptor) { // Create descriptor for this socket - socket_info->descriptor = std::make_unique< + socket_info->descriptor = std::make_shared< boost::asio::posix::stream_descriptor>(executor_); socket_info->descriptor->assign(socket_info->sockfd); } socket_info->action = action; - auto weak_self = weak_from_this(); curl_socket_t sockfd = socket_info->sockfd; + std::weak_ptr weak_descriptor = socket_info->descriptor; // Monitor for read events if (action & CURL_POLL_IN) { - socket_info->descriptor->async_wait( - boost::asio::posix::stream_descriptor::wait_read, - [weak_self, sockfd](const boost::system::error_code& ec) { - if (!ec) { - if (auto self = weak_self.lock()) { - self->handle_socket_action(sockfd, CURL_CSELECT_IN); - } - } - }); + start_read_monitor(sockfd, weak_descriptor); } // Monitor for write events if (action & CURL_POLL_OUT) { - socket_info->descriptor->async_wait( - boost::asio::posix::stream_descriptor::wait_write, - [weak_self, sockfd](const boost::system::error_code& ec) { - if (!ec) { - if (auto self = weak_self.lock()) { - self->handle_socket_action(sockfd, CURL_CSELECT_OUT); - } - } - }); + start_write_monitor(sockfd, weak_descriptor); + } +} + +void CurlMultiManager::start_read_monitor( + curl_socket_t sockfd, + std::weak_ptr weak_descriptor) { + + auto descriptor = weak_descriptor.lock(); + if (!descriptor) { + return; } + + auto weak_self = weak_from_this(); + + descriptor->async_wait( + boost::asio::posix::stream_descriptor::wait_read, + [weak_self, sockfd, weak_descriptor](const boost::system::error_code& ec) { + if (ec) { + return; + } + + auto self = weak_self.lock(); + if (!self) { + return; + } + + self->handle_socket_action(sockfd, CURL_CSELECT_IN); + + // Re-register for continuous monitoring + self->start_read_monitor(sockfd, weak_descriptor); + }); +} + +void CurlMultiManager::start_write_monitor( + curl_socket_t sockfd, + std::weak_ptr weak_descriptor) { + + auto descriptor = weak_descriptor.lock(); + if (!descriptor) { + return; + } + + auto weak_self = weak_from_this(); + + descriptor->async_wait( + boost::asio::posix::stream_descriptor::wait_write, + [weak_self, sockfd, weak_descriptor](const boost::system::error_code& ec) { + if (ec) { + return; + } + + auto self = weak_self.lock(); + if (!self) { + return; + } + + self->handle_socket_action(sockfd, CURL_CSELECT_OUT); + + // Re-register for continuous monitoring + self->start_write_monitor(sockfd, weak_descriptor); + }); } void CurlMultiManager::stop_socket_monitor(SocketInfo* socket_info) { diff --git a/libs/server-sent-events/src/curl_multi_manager.hpp b/libs/server-sent-events/src/curl_multi_manager.hpp index e987ef0a9..0582ba916 100644 --- a/libs/server-sent-events/src/curl_multi_manager.hpp +++ b/libs/server-sent-events/src/curl_multi_manager.hpp @@ -54,9 +54,10 @@ class CurlMultiManager : public std::enable_shared_from_this { /** * Add an easy handle to be managed. * @param easy The CURL easy handle (must be configured) + * @param headers The curl_slist headers (will be freed automatically) * @param callback Called when the transfer completes */ - void add_handle(CURL* easy, CompletionCallback callback); + void add_handle(CURL* easy, curl_slist* headers, CompletionCallback callback); /** * Remove an easy handle from management. @@ -86,19 +87,26 @@ class CurlMultiManager : public std::enable_shared_from_this { // Per-socket data struct SocketInfo { curl_socket_t sockfd; - std::unique_ptr descriptor; + std::shared_ptr descriptor; int action{0}; // CURL_POLL_IN, CURL_POLL_OUT, etc. }; void start_socket_monitor(SocketInfo* socket_info, int action); void stop_socket_monitor(SocketInfo* socket_info); + // Helper methods to start individual read/write monitoring (breaks circular ref) + void start_read_monitor(curl_socket_t sockfd, + std::weak_ptr weak_descriptor); + void start_write_monitor(curl_socket_t sockfd, + std::weak_ptr weak_descriptor); + boost::asio::any_io_executor executor_; CURLM* multi_handle_; boost::asio::steady_timer timer_; std::mutex mutex_; std::map callbacks_; + std::map headers_; int still_running_{0}; }; From 6223137d4144bd0e84a3edd4f577b8452e674ce8 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 16 Oct 2025 13:53:21 -0700 Subject: [PATCH 61/90] Refactor to have a shared networking library. --- .github/workflows/networking.yml | 20 ++ .release-please-manifest.json | 3 +- CMakeLists.txt | 7 + .../sse-contract-tests/CMakeLists.txt | 4 + libs/client-sdk/src/CMakeLists.txt | 4 + .../launchdarkly/network/curl_requester.hpp | 2 +- libs/internal/package.json | 3 +- libs/internal/src/CMakeLists.txt | 4 +- libs/networking/CMakeLists.txt | 17 + libs/networking/README.md | 39 +++ .../network/curl_multi_manager.hpp | 0 libs/networking/package.json | 6 + libs/networking/src/CMakeLists.txt | 45 +++ .../src}/curl_multi_manager.cpp | 0 libs/server-sdk/src/CMakeLists.txt | 4 + libs/server-sent-events/package.json | 4 +- libs/server-sent-events/src/CMakeLists.txt | 4 +- libs/server-sent-events/src/curl_client.hpp | 4 +- .../src/curl_multi_manager.cpp | 318 ------------------ .../src/curl_multi_manager.hpp | 115 ------- release-please-config.json | 3 +- 21 files changed, 163 insertions(+), 443 deletions(-) create mode 100644 .github/workflows/networking.yml create mode 100644 libs/networking/CMakeLists.txt create mode 100644 libs/networking/README.md rename libs/{internal => networking}/include/launchdarkly/network/curl_multi_manager.hpp (100%) create mode 100644 libs/networking/package.json create mode 100644 libs/networking/src/CMakeLists.txt rename libs/{internal/src/network => networking/src}/curl_multi_manager.cpp (100%) delete mode 100644 libs/server-sent-events/src/curl_multi_manager.cpp delete mode 100644 libs/server-sent-events/src/curl_multi_manager.hpp diff --git a/.github/workflows/networking.yml b/.github/workflows/networking.yml new file mode 100644 index 000000000..fcc5a9b59 --- /dev/null +++ b/.github/workflows/networking.yml @@ -0,0 +1,20 @@ +name: libs/networking + +on: + push: + branches: [ main ] + paths-ignore: + - '**.md' #Do not need to run CI for markdown changes. + pull_request: + branches: [ main, "feat/**" ] + paths-ignore: + - '**.md' + +jobs: + build-test-networking: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/ci + with: + cmake_target: launchdarkly-cpp-networking diff --git a/.release-please-manifest.json b/.release-please-manifest.json index a6783277b..0f6d9e1f1 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -4,5 +4,6 @@ "libs/common": "1.10.0", "libs/internal": "0.12.1", "libs/server-sdk": "3.9.1", - "libs/server-sdk-redis-source": "2.2.0" + "libs/server-sdk-redis-source": "2.2.0", + "libs/networking": "0.1.0" } diff --git a/CMakeLists.txt b/CMakeLists.txt index d06598c8d..2b07f0679 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -205,6 +205,13 @@ add_subdirectory(vendor/foxy) # Common, internal, and server-sent-events are built as "object" libraries. add_subdirectory(libs/common) + +if (LD_CURL_NETWORKING) + message(STATUS "LaunchDarkly: building networking library (CURL support)") + find_package(CURL REQUIRED) + add_subdirectory(libs/networking) +endif() + add_subdirectory(libs/internal) add_subdirectory(libs/server-sent-events) diff --git a/contract-tests/sse-contract-tests/CMakeLists.txt b/contract-tests/sse-contract-tests/CMakeLists.txt index 04494e9c2..f083fef91 100644 --- a/contract-tests/sse-contract-tests/CMakeLists.txt +++ b/contract-tests/sse-contract-tests/CMakeLists.txt @@ -27,4 +27,8 @@ target_link_libraries(sse-tests PRIVATE Boost::coroutine ) +if (LD_CURL_NETWORKING) + target_link_libraries(sse-tests PRIVATE launchdarkly::networking) +endif() + target_include_directories(sse-tests PUBLIC include) diff --git a/libs/client-sdk/src/CMakeLists.txt b/libs/client-sdk/src/CMakeLists.txt index edcde84b7..8c7e1d15c 100644 --- a/libs/client-sdk/src/CMakeLists.txt +++ b/libs/client-sdk/src/CMakeLists.txt @@ -44,6 +44,10 @@ target_link_libraries(${LIBNAME} PUBLIC launchdarkly::common PRIVATE Boost::headers Boost::json Boost::url launchdarkly::sse launchdarkly::internal foxy) +if (LD_CURL_NETWORKING) + target_link_libraries(${LIBNAME} PRIVATE launchdarkly::networking) +endif() + target_include_directories(${LIBNAME} PUBLIC diff --git a/libs/internal/include/launchdarkly/network/curl_requester.hpp b/libs/internal/include/launchdarkly/network/curl_requester.hpp index 94603d097..637478780 100644 --- a/libs/internal/include/launchdarkly/network/curl_requester.hpp +++ b/libs/internal/include/launchdarkly/network/curl_requester.hpp @@ -4,7 +4,7 @@ #include "http_requester.hpp" #include "asio_requester.hpp" -#include "curl_multi_manager.hpp" +#include #include #include diff --git a/libs/internal/package.json b/libs/internal/package.json index 457d29854..760017581 100644 --- a/libs/internal/package.json +++ b/libs/internal/package.json @@ -4,6 +4,7 @@ "version": "0.12.1", "private": true, "dependencies": { - "launchdarkly-cpp-common": "1.10.0" + "launchdarkly-cpp-common": "1.10.0", + "launchdarkly-cpp-networking": "0.1.0" } } diff --git a/libs/internal/src/CMakeLists.txt b/libs/internal/src/CMakeLists.txt index 67910175b..b249b03f5 100644 --- a/libs/internal/src/CMakeLists.txt +++ b/libs/internal/src/CMakeLists.txt @@ -50,7 +50,7 @@ set(INTERNAL_SOURCES if (LD_CURL_NETWORKING) message(STATUS "LaunchDarkly Internal: CURL networking enabled") find_package(CURL REQUIRED) - list(APPEND INTERNAL_SOURCES network/curl_requester.cpp network/curl_multi_manager.cpp) + list(APPEND INTERNAL_SOURCES network/curl_requester.cpp) endif() add_library(${LIBNAME} OBJECT ${INTERNAL_SOURCES}) @@ -70,7 +70,7 @@ target_link_libraries(${LIBNAME} PRIVATE Boost::url Boost::json OpenSSL::SSL Boost::disable_autolinking Boost::headers tl::expected foxy) if (LD_CURL_NETWORKING) - target_link_libraries(${LIBNAME} PRIVATE CURL::libcurl) + target_link_libraries(${LIBNAME} PRIVATE CURL::libcurl launchdarkly::networking) target_compile_definitions(${LIBNAME} PRIVATE LD_CURL_NETWORKING) endif() diff --git a/libs/networking/CMakeLists.txt b/libs/networking/CMakeLists.txt new file mode 100644 index 000000000..bf137b1da --- /dev/null +++ b/libs/networking/CMakeLists.txt @@ -0,0 +1,17 @@ +cmake_minimum_required(VERSION 3.19) + +project( + LaunchDarklyNetworking + VERSION 0.1.0 + DESCRIPTION "LaunchDarkly C++ Networking Library" + LANGUAGES CXX +) + +set(LIBNAME "launchdarkly-cpp-networking") + +if (CMAKE_PROJECT_NAME STREQUAL PROJECT_NAME) + set(CMAKE_CXX_EXTENSIONS OFF) + set_property(GLOBAL PROPERTY USE_FOLDERS ON) +endif () + +add_subdirectory(src) diff --git a/libs/networking/README.md b/libs/networking/README.md new file mode 100644 index 000000000..7bf0a1091 --- /dev/null +++ b/libs/networking/README.md @@ -0,0 +1,39 @@ +# LaunchDarkly C++ Networking Library + +This library provides common networking components for the LaunchDarkly C++ SDKs. + +Headers in this folder are intended to be used internally to LD SDKs and should not be used by application developers. + +Interfaces for these header files, such as method signatures, can change without major versions. + +## Components + +### CurlMultiManager + +Manages asynchronous HTTP operations using the CURL multi interface integrated with Boost.ASIO for non-blocking socket monitoring. + +Features: +- Non-blocking HTTP requests using `curl_multi_socket_action()` +- Integration with Boost.ASIO event loop +- Continuous socket monitoring for long-lived connections (e.g., SSE) +- Automatic cleanup and resource management + +## Usage + +This library is used internally by: +- `launchdarkly-cpp-internal` - For general HTTP requests +- `launchdarkly-cpp-sse` - For server-sent events streaming + +## Building + +This library requires: +- CMake 3.19+ +- C++17 compiler +- libcurl +- Boost (ASIO, System) + +Build with `LD_CURL_NETWORKING` flag enabled: +```bash +cmake -DLD_CURL_NETWORKING=ON .. +cmake --build . +``` diff --git a/libs/internal/include/launchdarkly/network/curl_multi_manager.hpp b/libs/networking/include/launchdarkly/network/curl_multi_manager.hpp similarity index 100% rename from libs/internal/include/launchdarkly/network/curl_multi_manager.hpp rename to libs/networking/include/launchdarkly/network/curl_multi_manager.hpp diff --git a/libs/networking/package.json b/libs/networking/package.json new file mode 100644 index 000000000..087e922d5 --- /dev/null +++ b/libs/networking/package.json @@ -0,0 +1,6 @@ +{ + "name": "launchdarkly-cpp-networking", + "description": "This package.json exists for modeling dependencies for the release process.", + "version": "0.1.0", + "private": true +} diff --git a/libs/networking/src/CMakeLists.txt b/libs/networking/src/CMakeLists.txt new file mode 100644 index 000000000..b1a21abae --- /dev/null +++ b/libs/networking/src/CMakeLists.txt @@ -0,0 +1,45 @@ + +file(GLOB HEADER_LIST CONFIGURE_DEPENDS + "${LaunchDarklyNetworking_SOURCE_DIR}/include/launchdarkly/network/*.hpp" +) + +set(NETWORKING_SOURCES + ${HEADER_LIST} + curl_multi_manager.cpp +) + +add_library(${LIBNAME} OBJECT ${NETWORKING_SOURCES}) + +add_library(launchdarkly::networking ALIAS ${LIBNAME}) + +message(STATUS "LaunchDarklyNetworking_SOURCE_DIR=${LaunchDarklyNetworking_SOURCE_DIR}") + +target_link_libraries(${LIBNAME} + PRIVATE + Boost::headers + CURL::libcurl + Boost::disable_autolinking +) + +# Need the public headers to build. +target_include_directories(${LIBNAME} + PUBLIC + $ + $ +) + +# Minimum C++ standard needed for consuming the public API is C++17. +target_compile_features(${LIBNAME} PUBLIC cxx_std_17) + +target_compile_definitions(${LIBNAME} PUBLIC LD_CURL_NETWORKING) + +# Using PUBLIC_HEADERS would flatten the include. +# This will preserve it, but dependencies must do the same. +install(DIRECTORY "${LaunchDarklyNetworking_SOURCE_DIR}/include/launchdarkly" + DESTINATION "include" +) + +install( + TARGETS ${LIBNAME} + EXPORT ${LD_TARGETS_EXPORT_NAME} +) diff --git a/libs/internal/src/network/curl_multi_manager.cpp b/libs/networking/src/curl_multi_manager.cpp similarity index 100% rename from libs/internal/src/network/curl_multi_manager.cpp rename to libs/networking/src/curl_multi_manager.cpp diff --git a/libs/server-sdk/src/CMakeLists.txt b/libs/server-sdk/src/CMakeLists.txt index 15543fb4a..b991ad15d 100644 --- a/libs/server-sdk/src/CMakeLists.txt +++ b/libs/server-sdk/src/CMakeLists.txt @@ -79,6 +79,10 @@ target_link_libraries(${LIBNAME} PUBLIC launchdarkly::common PRIVATE Boost::headers Boost::json Boost::url launchdarkly::sse launchdarkly::internal foxy timestamp) +if (LD_CURL_NETWORKING) + target_link_libraries(${LIBNAME} PRIVATE launchdarkly::networking) +endif() + add_library(launchdarkly::server ALIAS ${LIBNAME}) diff --git a/libs/server-sent-events/package.json b/libs/server-sent-events/package.json index 503f73377..45e1af626 100644 --- a/libs/server-sent-events/package.json +++ b/libs/server-sent-events/package.json @@ -3,5 +3,7 @@ "description": "This package.json exists for modeling dependencies for the release process.", "private": true, "version": "0.5.5", - "dependencies": {} + "dependencies": { + "launchdarkly-cpp-networking": "0.1.0" + } } diff --git a/libs/server-sent-events/src/CMakeLists.txt b/libs/server-sent-events/src/CMakeLists.txt index d11385baf..c3a4d36dd 100644 --- a/libs/server-sent-events/src/CMakeLists.txt +++ b/libs/server-sent-events/src/CMakeLists.txt @@ -17,7 +17,7 @@ set(SSE_SOURCES if (LD_CURL_NETWORKING) message(STATUS "LaunchDarkly SSE: CURL networking enabled") find_package(CURL REQUIRED) - list(APPEND SSE_SOURCES curl_client.cpp curl_multi_manager.cpp) + list(APPEND SSE_SOURCES curl_client.cpp) endif() add_library(${LIBNAME} OBJECT ${SSE_SOURCES}) @@ -28,7 +28,7 @@ target_link_libraries(${LIBNAME} ) if (LD_CURL_NETWORKING) - target_link_libraries(${LIBNAME} PRIVATE CURL::libcurl) + target_link_libraries(${LIBNAME} PRIVATE CURL::libcurl launchdarkly::networking) target_compile_definitions(${LIBNAME} PRIVATE LD_CURL_NETWORKING) endif() diff --git a/libs/server-sent-events/src/curl_client.hpp b/libs/server-sent-events/src/curl_client.hpp index f1a5ce1a7..d045b0652 100644 --- a/libs/server-sent-events/src/curl_client.hpp +++ b/libs/server-sent-events/src/curl_client.hpp @@ -3,8 +3,8 @@ #ifdef LD_CURL_NETWORKING #include +#include #include "backoff.hpp" -#include "curl_multi_manager.hpp" #include "parser.hpp" #include @@ -24,6 +24,8 @@ namespace launchdarkly::sse { namespace http = beast::http; namespace net = boost::asio; +using launchdarkly::network::CurlMultiManager; + /** * The lifecycle of the CurlClient is managed in an RAII manner. This introduces * some complexity with interaction with CURL, which requires a thread to be diff --git a/libs/server-sent-events/src/curl_multi_manager.cpp b/libs/server-sent-events/src/curl_multi_manager.cpp deleted file mode 100644 index b85273be9..000000000 --- a/libs/server-sent-events/src/curl_multi_manager.cpp +++ /dev/null @@ -1,318 +0,0 @@ -#ifdef LD_CURL_NETWORKING - -#include "curl_multi_manager.hpp" - -#include - -#include - -namespace launchdarkly::sse { - -std::shared_ptr CurlMultiManager::create( - boost::asio::any_io_executor executor) { - // Can't use make_shared because constructor is private - return std::shared_ptr( - new CurlMultiManager(std::move(executor))); -} - -CurlMultiManager::CurlMultiManager(boost::asio::any_io_executor executor) - : executor_(std::move(executor)), - multi_handle_(curl_multi_init()), - timer_(executor_) { - - if (!multi_handle_) { - throw std::runtime_error("Failed to initialize CURL multi handle"); - } - - // Set callbacks for socket and timer notifications - curl_multi_setopt(multi_handle_, CURLMOPT_SOCKETFUNCTION, socket_callback); - curl_multi_setopt(multi_handle_, CURLMOPT_SOCKETDATA, this); - curl_multi_setopt(multi_handle_, CURLMOPT_TIMERFUNCTION, timer_callback); - curl_multi_setopt(multi_handle_, CURLMOPT_TIMERDATA, this); -} - -CurlMultiManager::~CurlMultiManager() { - if (multi_handle_) { - // Extract and clear pending handles, callbacks, and headers - std::map pending_callbacks; - std::map pending_headers; - { - std::lock_guard lock(mutex_); - pending_callbacks = std::move(callbacks_); - pending_headers = std::move(headers_); - callbacks_.clear(); - headers_.clear(); - } - - // Remove handles from multi and cleanup resources - // Do NOT invoke callbacks as they may access destroyed objects - for (auto& [easy, callback] : pending_callbacks) { - curl_multi_remove_handle(multi_handle_, easy); - - // Free headers if they exist for this handle - auto header_it = pending_headers.find(easy); - if (header_it != pending_headers.end() && header_it->second) { - curl_slist_free_all(header_it->second); - } - - curl_easy_cleanup(easy); - } - - curl_multi_cleanup(multi_handle_); - } -} - -void CurlMultiManager::add_handle(CURL* easy, curl_slist* headers, CompletionCallback callback) { - { - std::lock_guard lock(mutex_); - callbacks_[easy] = std::move(callback); - headers_[easy] = headers; - } - - CURLMcode rc = curl_multi_add_handle(multi_handle_, easy); - if (rc != CURLM_OK) { - std::lock_guard lock(mutex_); - callbacks_.erase(easy); - - // Free headers on error - auto header_it = headers_.find(easy); - if (header_it != headers_.end() && header_it->second) { - curl_slist_free_all(header_it->second); - } - headers_.erase(easy); - - std::cerr << "Failed to add handle to multi: " - << curl_multi_strerror(rc) << std::endl; - } -} - -void CurlMultiManager::remove_handle(CURL* easy) { - curl_multi_remove_handle(multi_handle_, easy); - - std::lock_guard lock(mutex_); - callbacks_.erase(easy); - - // Free headers if they exist - auto header_it = headers_.find(easy); - if (header_it != headers_.end() && header_it->second) { - curl_slist_free_all(header_it->second); - } - headers_.erase(easy); -} - -int CurlMultiManager::socket_callback(CURL* easy, curl_socket_t s, int what, - void* userp, void* socketp) { - auto* manager = static_cast(userp); - auto* socket_info = static_cast(socketp); - - if (what == CURL_POLL_REMOVE) { - if (socket_info) { - manager->stop_socket_monitor(socket_info); - curl_multi_assign(manager->multi_handle_, s, nullptr); - delete socket_info; - } - } else { - if (!socket_info) { - // New socket - socket_info = new SocketInfo{s, nullptr, 0}; - curl_multi_assign(manager->multi_handle_, s, socket_info); - } - - manager->start_socket_monitor(socket_info, what); - } - - return 0; -} - -int CurlMultiManager::timer_callback(CURLM* multi, long timeout_ms, void* userp) { - auto* manager = static_cast(userp); - - // Cancel any existing timer - manager->timer_.cancel(); - - if (timeout_ms > 0) { - // Set new timer - manager->timer_.expires_after(std::chrono::milliseconds(timeout_ms)); - manager->timer_.async_wait([weak_self = manager->weak_from_this()]( - const boost::system::error_code& ec) { - if (!ec) { - if (auto self = weak_self.lock()) { - self->handle_timeout(); - } - } - }); - } else if (timeout_ms == 0) { - // Call socket_action immediately - boost::asio::post(manager->executor_, [weak_self = manager->weak_from_this()]() { - if (auto self = weak_self.lock()) { - self->handle_timeout(); - } - }); - } - // If timeout_ms < 0, no timeout (delete timer) - - return 0; -} - -void CurlMultiManager::handle_socket_action(curl_socket_t s, int event_bitmask) { - int running_handles = 0; - CURLMcode rc = curl_multi_socket_action(multi_handle_, s, event_bitmask, - &running_handles); - - if (rc != CURLM_OK) { - std::cerr << "curl_multi_socket_action failed: " - << curl_multi_strerror(rc) << std::endl; - } - - check_multi_info(); - - if (running_handles != still_running_) { - still_running_ = running_handles; - } -} - -void CurlMultiManager::handle_timeout() { - handle_socket_action(CURL_SOCKET_TIMEOUT, 0); -} - -void CurlMultiManager::check_multi_info() { - int msgs_in_queue; - CURLMsg* msg; - - while ((msg = curl_multi_info_read(multi_handle_, &msgs_in_queue))) { - if (msg->msg == CURLMSG_DONE) { - CURL* easy = msg->easy_handle; - CURLcode result = msg->data.result; - - CompletionCallback callback; - curl_slist* headers = nullptr; - { - std::lock_guard lock(mutex_); - auto it = callbacks_.find(easy); - if (it != callbacks_.end()) { - callback = std::move(it->second); - callbacks_.erase(it); - } - - // Get and remove headers - auto header_it = headers_.find(easy); - if (header_it != headers_.end()) { - headers = header_it->second; - headers_.erase(header_it); - } - } - - // Remove from multi handle - curl_multi_remove_handle(multi_handle_, easy); - - // Free headers - if (headers) { - curl_slist_free_all(headers); - } - - // Invoke completion callback - if (callback) { - boost::asio::post(executor_, [callback = std::move(callback), - easy, result]() { - callback(easy, result); - }); - } - } - } -} - -void CurlMultiManager::start_socket_monitor(SocketInfo* socket_info, int action) { - if (!socket_info->descriptor) { - // Create descriptor for this socket - socket_info->descriptor = std::make_shared< - boost::asio::posix::stream_descriptor>(executor_); - socket_info->descriptor->assign(socket_info->sockfd); - } - - socket_info->action = action; - - curl_socket_t sockfd = socket_info->sockfd; - std::weak_ptr weak_descriptor = socket_info->descriptor; - - // Monitor for read events - if (action & CURL_POLL_IN) { - start_read_monitor(sockfd, weak_descriptor); - } - - // Monitor for write events - if (action & CURL_POLL_OUT) { - start_write_monitor(sockfd, weak_descriptor); - } -} - -void CurlMultiManager::start_read_monitor( - curl_socket_t sockfd, - std::weak_ptr weak_descriptor) { - - auto descriptor = weak_descriptor.lock(); - if (!descriptor) { - return; - } - - auto weak_self = weak_from_this(); - - descriptor->async_wait( - boost::asio::posix::stream_descriptor::wait_read, - [weak_self, sockfd, weak_descriptor](const boost::system::error_code& ec) { - if (ec) { - return; - } - - auto self = weak_self.lock(); - if (!self) { - return; - } - - self->handle_socket_action(sockfd, CURL_CSELECT_IN); - - // Re-register for continuous monitoring - self->start_read_monitor(sockfd, weak_descriptor); - }); -} - -void CurlMultiManager::start_write_monitor( - curl_socket_t sockfd, - std::weak_ptr weak_descriptor) { - - auto descriptor = weak_descriptor.lock(); - if (!descriptor) { - return; - } - - auto weak_self = weak_from_this(); - - descriptor->async_wait( - boost::asio::posix::stream_descriptor::wait_write, - [weak_self, sockfd, weak_descriptor](const boost::system::error_code& ec) { - if (ec) { - return; - } - - auto self = weak_self.lock(); - if (!self) { - return; - } - - self->handle_socket_action(sockfd, CURL_CSELECT_OUT); - - // Re-register for continuous monitoring - self->start_write_monitor(sockfd, weak_descriptor); - }); -} - -void CurlMultiManager::stop_socket_monitor(SocketInfo* socket_info) { - if (socket_info->descriptor) { - socket_info->descriptor->cancel(); - socket_info->descriptor->release(); - socket_info->descriptor.reset(); - } -} - -} // namespace launchdarkly::sse - -#endif // LD_CURL_NETWORKING diff --git a/libs/server-sent-events/src/curl_multi_manager.hpp b/libs/server-sent-events/src/curl_multi_manager.hpp deleted file mode 100644 index 0582ba916..000000000 --- a/libs/server-sent-events/src/curl_multi_manager.hpp +++ /dev/null @@ -1,115 +0,0 @@ -#pragma once - -#ifdef LD_CURL_NETWORKING - -#include -#include -#include -#include - -#include -#include -#include -#include - -namespace launchdarkly::sse { - -/** - * Manages CURL multi interface integrated with ASIO event loop. - * - * This class provides non-blocking HTTP operations by integrating CURL's - * multi interface with Boost.ASIO. Instead of blocking threads, CURL notifies - * us via callbacks when sockets need attention, and we use ASIO to monitor - * those sockets asynchronously. - * - * Key features: - * - Non-blocking I/O using curl_multi_socket_action - * - Socket monitoring via ASIO stream_descriptor - * - Timer integration with ASIO steady_timer - * - Thread-safe operation on ASIO executor - */ -class CurlMultiManager : public std::enable_shared_from_this { -public: - /** - * Callback invoked when an easy handle completes (success or error). - * Parameters: CURL* easy handle, CURLcode result - */ - using CompletionCallback = std::function; - - /** - * Create a CurlMultiManager on the given executor. - * @param executor The ASIO executor to run operations on - */ - static std::shared_ptr create( - boost::asio::any_io_executor executor); - - ~CurlMultiManager(); - - // Non-copyable and non-movable - CurlMultiManager(const CurlMultiManager&) = delete; - CurlMultiManager& operator=(const CurlMultiManager&) = delete; - CurlMultiManager(CurlMultiManager&&) = delete; - CurlMultiManager& operator=(CurlMultiManager&&) = delete; - - /** - * Add an easy handle to be managed. - * @param easy The CURL easy handle (must be configured) - * @param headers The curl_slist headers (will be freed automatically) - * @param callback Called when the transfer completes - */ - void add_handle(CURL* easy, curl_slist* headers, CompletionCallback callback); - - /** - * Remove an easy handle from management. - * @param easy The CURL easy handle to remove - */ - void remove_handle(CURL* easy); - -private: - explicit CurlMultiManager(boost::asio::any_io_executor executor); - - // Called by CURL when socket state changes - static int socket_callback(CURL* easy, curl_socket_t s, int what, - void* userp, void* socketp); - - // Called by CURL when timer should be set - static int timer_callback(CURLM* multi, long timeout_ms, void* userp); - - // Handle socket events - void handle_socket_action(curl_socket_t s, int event_bitmask); - - // Handle timer expiry - void handle_timeout(); - - // Check for completed transfers - void check_multi_info(); - - // Per-socket data - struct SocketInfo { - curl_socket_t sockfd; - std::shared_ptr descriptor; - int action{0}; // CURL_POLL_IN, CURL_POLL_OUT, etc. - }; - - void start_socket_monitor(SocketInfo* socket_info, int action); - void stop_socket_monitor(SocketInfo* socket_info); - - // Helper methods to start individual read/write monitoring (breaks circular ref) - void start_read_monitor(curl_socket_t sockfd, - std::weak_ptr weak_descriptor); - void start_write_monitor(curl_socket_t sockfd, - std::weak_ptr weak_descriptor); - - boost::asio::any_io_executor executor_; - CURLM* multi_handle_; - boost::asio::steady_timer timer_; - - std::mutex mutex_; - std::map callbacks_; - std::map headers_; - int still_running_{0}; -}; - -} // namespace launchdarkly::sse - -#endif // LD_CURL_NETWORKING diff --git a/release-please-config.json b/release-please-config.json index b99bcb78f..52016632f 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -26,6 +26,7 @@ }, "libs/server-sent-events": {}, "libs/common": {}, - "libs/internal": {} + "libs/internal": {}, + "libs/networking": {} } } From fce49960128f587821b579fe209686530a0ddcec Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 16 Oct 2025 14:41:13 -0700 Subject: [PATCH 62/90] Cmake updates. --- libs/common/tests/CMakeLists.txt | 4 ++++ libs/internal/tests/CMakeLists.txt | 1 + libs/server-sent-events/tests/CMakeLists.txt | 1 + 3 files changed, 6 insertions(+) diff --git a/libs/common/tests/CMakeLists.txt b/libs/common/tests/CMakeLists.txt index 88e24a280..2d48deab5 100644 --- a/libs/common/tests/CMakeLists.txt +++ b/libs/common/tests/CMakeLists.txt @@ -20,4 +20,8 @@ add_executable(gtest_${LIBNAME} ${tests}) target_link_libraries(gtest_${LIBNAME} launchdarkly::common launchdarkly::internal foxy GTest::gtest_main) +if (LD_CURL_NETWORKING) + target_link_libraries(gtest_${LIBNAME} launchdarkly::networking) +endif() + gtest_discover_tests(gtest_${LIBNAME}) diff --git a/libs/internal/tests/CMakeLists.txt b/libs/internal/tests/CMakeLists.txt index 94d9141e2..dff41da87 100644 --- a/libs/internal/tests/CMakeLists.txt +++ b/libs/internal/tests/CMakeLists.txt @@ -20,6 +20,7 @@ target_link_libraries(gtest_${LIBNAME} launchdarkly::common launchdarkly::intern if (LD_CURL_NETWORKING) target_compile_definitions(gtest_${LIBNAME} PRIVATE LD_CURL_NETWORKING) + target_link_libraries(gtest_${LIBNAME} launchdarkly::networking) endif() gtest_discover_tests(gtest_${LIBNAME}) diff --git a/libs/server-sent-events/tests/CMakeLists.txt b/libs/server-sent-events/tests/CMakeLists.txt index 5a1b5d813..2e659378c 100644 --- a/libs/server-sent-events/tests/CMakeLists.txt +++ b/libs/server-sent-events/tests/CMakeLists.txt @@ -20,6 +20,7 @@ target_link_libraries(gtest_${LIBNAME} launchdarkly::sse foxy GTest::gtest_main if (LD_CURL_NETWORKING) target_compile_definitions(gtest_${LIBNAME} PRIVATE LD_CURL_NETWORKING) + target_link_libraries(gtest_${LIBNAME} launchdarkly::networking) endif() gtest_discover_tests(gtest_${LIBNAME}) From 9892c7bcca8cc175d97657e8e1e2fa7e8ac25883 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 16 Oct 2025 17:08:51 -0700 Subject: [PATCH 63/90] Ensure descriptors and handlers are not leaking. --- .../network/curl_multi_manager.hpp | 4 + libs/networking/src/curl_multi_manager.cpp | 171 ++++++++++-------- 2 files changed, 102 insertions(+), 73 deletions(-) diff --git a/libs/networking/include/launchdarkly/network/curl_multi_manager.hpp b/libs/networking/include/launchdarkly/network/curl_multi_manager.hpp index 445eb8d69..9a92e87ea 100644 --- a/libs/networking/include/launchdarkly/network/curl_multi_manager.hpp +++ b/libs/networking/include/launchdarkly/network/curl_multi_manager.hpp @@ -89,6 +89,9 @@ class CurlMultiManager : public std::enable_shared_from_this { curl_socket_t sockfd; std::shared_ptr descriptor; int action{0}; // CURL_POLL_IN, CURL_POLL_OUT, etc. + // Keep handlers alive - we own them and they only capture weak_ptr to avoid circular refs + std::shared_ptr> read_handler; + std::shared_ptr> write_handler; }; void start_socket_monitor(SocketInfo* socket_info, int action); @@ -101,6 +104,7 @@ class CurlMultiManager : public std::enable_shared_from_this { std::mutex mutex_; std::map callbacks_; std::map headers_; + std::map sockets_; // Managed socket info int still_running_{0}; }; diff --git a/libs/networking/src/curl_multi_manager.cpp b/libs/networking/src/curl_multi_manager.cpp index b12440f5f..96900b3af 100644 --- a/libs/networking/src/curl_multi_manager.cpp +++ b/libs/networking/src/curl_multi_manager.cpp @@ -33,15 +33,23 @@ CurlMultiManager::CurlMultiManager(boost::asio::any_io_executor executor) CurlMultiManager::~CurlMultiManager() { if (multi_handle_) { - // Extract and clear pending handles, callbacks, and headers + // Extract and clear pending handles, callbacks, headers, and sockets std::map pending_callbacks; std::map pending_headers; + std::map pending_sockets; { std::lock_guard lock(mutex_); pending_callbacks = std::move(callbacks_); pending_headers = std::move(headers_); + pending_sockets = std::move(sockets_); callbacks_.clear(); headers_.clear(); + sockets_.clear(); + } + + // Clean up all remaining sockets + for (auto& [sockfd, socket_info] : pending_sockets) { + stop_socket_monitor(&socket_info); } // Remove handles from multi and cleanup resources @@ -103,22 +111,20 @@ void CurlMultiManager::remove_handle(CURL* easy) { int CurlMultiManager::socket_callback(CURL* easy, curl_socket_t s, int what, void* userp, void* socketp) { auto* manager = static_cast(userp); - auto* socket_info = static_cast(socketp); + + std::lock_guard lock(manager->mutex_); if (what == CURL_POLL_REMOVE) { - if (socket_info) { - manager->stop_socket_monitor(socket_info); - curl_multi_assign(manager->multi_handle_, s, nullptr); - delete socket_info; + // Remove socket from managed container + auto it = manager->sockets_.find(s); + if (it != manager->sockets_.end()) { + manager->stop_socket_monitor(&it->second); + manager->sockets_.erase(it); } } else { - if (!socket_info) { - // New socket - socket_info = new SocketInfo{s, nullptr, 0}; - curl_multi_assign(manager->multi_handle_, s, socket_info); - } - - manager->start_socket_monitor(socket_info, what); + // Add or update socket in managed container + auto [it, inserted] = manager->sockets_.try_emplace(s, SocketInfo{s, nullptr, 0}); + manager->start_socket_monitor(&it->second, what); } return 0; @@ -229,6 +235,8 @@ void CurlMultiManager::start_socket_monitor(SocketInfo* socket_info, int action) socket_info->descriptor->assign(socket_info->sockfd); } + // Check if action has changed + bool action_changed = (socket_info->action != action); socket_info->action = action; auto weak_self = weak_from_this(); @@ -236,72 +244,86 @@ void CurlMultiManager::start_socket_monitor(SocketInfo* socket_info, int action) // Monitor for read events if (action & CURL_POLL_IN) { - // Use weak_ptr to safely detect when descriptor is deleted - std::weak_ptr weak_descriptor = socket_info->descriptor; - - // Use shared_ptr for recursive lambda - auto read_handler = std::make_shared>(); - *read_handler = [weak_self, sockfd, weak_descriptor, read_handler]() { - // Check if manager and descriptor are still valid - auto self = weak_self.lock(); - auto descriptor = weak_descriptor.lock(); - if (!self || !descriptor) { - return; - } + // Only create new handler if we don't have one or if action changed + if (!socket_info->read_handler || action_changed) { + // Use weak_ptr to safely detect when descriptor is deleted + std::weak_ptr weak_descriptor = socket_info->descriptor; + + // Create and store handler in SocketInfo to keep it alive + // Use weak_ptr in capture to avoid circular reference + socket_info->read_handler = std::make_shared>(); + std::weak_ptr> weak_read_handler = socket_info->read_handler; + *socket_info->read_handler = [weak_self, sockfd, weak_descriptor, weak_read_handler]() { + // Check if manager and descriptor are still valid + auto self = weak_self.lock(); + auto descriptor = weak_descriptor.lock(); + if (!self || !descriptor) { + return; + } - descriptor->async_wait( - boost::asio::posix::stream_descriptor::wait_read, - [weak_self, sockfd, weak_descriptor, read_handler](const boost::system::error_code& ec) { - // If operation was canceled or had an error, don't re-register - if (ec) { - return; - } - - if (auto self = weak_self.lock()) { - self->handle_socket_action(sockfd, CURL_CSELECT_IN); - - // Always try to re-register for continuous monitoring - // The validity check at the top of read_handler will stop it if needed - (*read_handler)(); // Recursive call - } - }); - }; - (*read_handler)(); // Initial call + descriptor->async_wait( + boost::asio::posix::stream_descriptor::wait_read, + [weak_self, sockfd, weak_descriptor, weak_read_handler](const boost::system::error_code& ec) { + // If operation was canceled or had an error, don't re-register + if (ec) { + return; + } + + if (auto self = weak_self.lock()) { + self->handle_socket_action(sockfd, CURL_CSELECT_IN); + + // Always try to re-register for continuous monitoring + // The validity check at the top of read_handler will stop it if needed + if (auto handler = weak_read_handler.lock()) { + (*handler)(); // Recursive call + } + } + }); + }; + (*socket_info->read_handler)(); // Initial call + } } // Monitor for write events if (action & CURL_POLL_OUT) { - // Use weak_ptr to safely detect when descriptor is deleted - std::weak_ptr weak_descriptor = socket_info->descriptor; - - // Use shared_ptr for recursive lambda - auto write_handler = std::make_shared>(); - *write_handler = [weak_self, sockfd, weak_descriptor, write_handler]() { - // Check if manager and descriptor are still valid - auto self = weak_self.lock(); - auto descriptor = weak_descriptor.lock(); - if (!self || !descriptor) { - return; - } + // Only create new handler if we don't have one or if action changed + if (!socket_info->write_handler || action_changed) { + // Use weak_ptr to safely detect when descriptor is deleted + std::weak_ptr weak_descriptor = socket_info->descriptor; + + // Create and store handler in SocketInfo to keep it alive + // Use weak_ptr in capture to avoid circular reference + socket_info->write_handler = std::make_shared>(); + std::weak_ptr> weak_write_handler = socket_info->write_handler; + *socket_info->write_handler = [weak_self, sockfd, weak_descriptor, weak_write_handler]() { + // Check if manager and descriptor are still valid + auto self = weak_self.lock(); + auto descriptor = weak_descriptor.lock(); + if (!self || !descriptor) { + return; + } - descriptor->async_wait( - boost::asio::posix::stream_descriptor::wait_write, - [weak_self, sockfd, weak_descriptor, write_handler](const boost::system::error_code& ec) { - // If operation was canceled or had an error, don't re-register - if (ec) { - return; - } - - if (auto self = weak_self.lock()) { - self->handle_socket_action(sockfd, CURL_CSELECT_OUT); - - // Always try to re-register for continuous monitoring - // The validity check at the top of write_handler will stop it if needed - (*write_handler)(); // Recursive call - } - }); - }; - (*write_handler)(); // Initial call + descriptor->async_wait( + boost::asio::posix::stream_descriptor::wait_write, + [weak_self, sockfd, weak_descriptor, weak_write_handler](const boost::system::error_code& ec) { + // If operation was canceled or had an error, don't re-register + if (ec) { + return; + } + + if (auto self = weak_self.lock()) { + self->handle_socket_action(sockfd, CURL_CSELECT_OUT); + + // Always try to re-register for continuous monitoring + // The validity check at the top of write_handler will stop it if needed + if (auto handler = weak_write_handler.lock()) { + (*handler)(); // Recursive call + } + } + }); + }; + (*socket_info->write_handler)(); // Initial call + } } } @@ -311,6 +333,9 @@ void CurlMultiManager::stop_socket_monitor(SocketInfo* socket_info) { socket_info->descriptor->release(); socket_info->descriptor.reset(); } + // Clear handlers to break any weak_ptr references + socket_info->read_handler.reset(); + socket_info->write_handler.reset(); } } // namespace launchdarkly::network From 87c0fbc8f40c2810805a5aca372a6e8cd334d344 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 16 Oct 2025 17:23:20 -0700 Subject: [PATCH 64/90] Start simplifying memory management. --- .../network/curl_multi_manager.hpp | 21 +- libs/networking/src/curl_multi_manager.cpp | 286 +++++++++--------- 2 files changed, 149 insertions(+), 158 deletions(-) diff --git a/libs/networking/include/launchdarkly/network/curl_multi_manager.hpp b/libs/networking/include/launchdarkly/network/curl_multi_manager.hpp index 9a92e87ea..9529d0769 100644 --- a/libs/networking/include/launchdarkly/network/curl_multi_manager.hpp +++ b/libs/networking/include/launchdarkly/network/curl_multi_manager.hpp @@ -13,7 +13,6 @@ #include namespace launchdarkly::network { - /** * Manages CURL multi interface integrated with ASIO event loop. * @@ -57,7 +56,9 @@ class CurlMultiManager : public std::enable_shared_from_this { * @param headers The curl_slist headers (will be freed automatically) * @param callback Called when the transfer completes */ - void add_handle(CURL* easy, curl_slist* headers, CompletionCallback callback); + void add_handle(CURL* easy, + curl_slist* headers, + CompletionCallback callback); /** * Remove an easy handle from management. @@ -69,8 +70,11 @@ class CurlMultiManager : public std::enable_shared_from_this { explicit CurlMultiManager(boost::asio::any_io_executor executor); // Called by CURL when socket state changes - static int socket_callback(CURL* easy, curl_socket_t s, int what, - void* userp, void* socketp); + static int socket_callback(CURL* easy, + curl_socket_t s, + int what, + void* userp, + void* socketp); // Called by CURL when timer should be set static int timer_callback(CURLM* multi, long timeout_ms, void* userp); @@ -95,19 +99,18 @@ class CurlMultiManager : public std::enable_shared_from_this { }; void start_socket_monitor(SocketInfo* socket_info, int action); - void stop_socket_monitor(SocketInfo* socket_info); boost::asio::any_io_executor executor_; - CURLM* multi_handle_; + // CURLM* multi_handle_; + std::unique_ptr multi_handle_; boost::asio::steady_timer timer_; std::mutex mutex_; std::map callbacks_; std::map headers_; - std::map sockets_; // Managed socket info + std::map sockets_; // Managed socket info int still_running_{0}; }; - } // namespace launchdarkly::network -#endif // LD_CURL_NETWORKING +#endif // LD_CURL_NETWORKING \ No newline at end of file diff --git a/libs/networking/src/curl_multi_manager.cpp b/libs/networking/src/curl_multi_manager.cpp index 96900b3af..2a13a7757 100644 --- a/libs/networking/src/curl_multi_manager.cpp +++ b/libs/networking/src/curl_multi_manager.cpp @@ -7,7 +7,6 @@ #include namespace launchdarkly::network { - std::shared_ptr CurlMultiManager::create( boost::asio::any_io_executor executor) { // Can't use make_shared because constructor is private @@ -17,120 +16,106 @@ std::shared_ptr CurlMultiManager::create( CurlMultiManager::CurlMultiManager(boost::asio::any_io_executor executor) : executor_(std::move(executor)), - multi_handle_(curl_multi_init()), + multi_handle_(curl_multi_init(), &curl_multi_cleanup), timer_(executor_) { - if (!multi_handle_) { throw std::runtime_error("Failed to initialize CURL multi handle"); } + CURLM* pmulti = multi_handle_.get(); // Set callbacks for socket and timer notifications - curl_multi_setopt(multi_handle_, CURLMOPT_SOCKETFUNCTION, socket_callback); - curl_multi_setopt(multi_handle_, CURLMOPT_SOCKETDATA, this); - curl_multi_setopt(multi_handle_, CURLMOPT_TIMERFUNCTION, timer_callback); - curl_multi_setopt(multi_handle_, CURLMOPT_TIMERDATA, this); + curl_multi_setopt(pmulti, CURLMOPT_SOCKETFUNCTION, socket_callback); + curl_multi_setopt(pmulti, CURLMOPT_SOCKETDATA, this); + curl_multi_setopt(pmulti, CURLMOPT_TIMERFUNCTION, timer_callback); + curl_multi_setopt(pmulti, CURLMOPT_TIMERDATA, this); } CurlMultiManager::~CurlMultiManager() { if (multi_handle_) { - // Extract and clear pending handles, callbacks, headers, and sockets - std::map pending_callbacks; - std::map pending_headers; - std::map pending_sockets; - { - std::lock_guard lock(mutex_); - pending_callbacks = std::move(callbacks_); - pending_headers = std::move(headers_); - pending_sockets = std::move(sockets_); - callbacks_.clear(); - headers_.clear(); - sockets_.clear(); - } - - // Clean up all remaining sockets - for (auto& [sockfd, socket_info] : pending_sockets) { - stop_socket_monitor(&socket_info); - } - // Remove handles from multi and cleanup resources // Do NOT invoke callbacks as they may access destroyed objects - for (auto& [easy, callback] : pending_callbacks) { - curl_multi_remove_handle(multi_handle_, easy); + for (auto& [easy, callback] : callbacks_) { + curl_multi_remove_handle(multi_handle_.get(), easy); // Free headers if they exist for this handle - auto header_it = pending_headers.find(easy); - if (header_it != pending_headers.end() && header_it->second) { + if (auto header_it = headers_.find(easy); + header_it != headers_.end() && header_it->second) { curl_slist_free_all(header_it->second); } curl_easy_cleanup(easy); } - - curl_multi_cleanup(multi_handle_); } } -void CurlMultiManager::add_handle(CURL* easy, curl_slist* headers, CompletionCallback callback) { +void CurlMultiManager::add_handle(CURL* easy, + curl_slist* headers, + CompletionCallback callback) { { - std::lock_guard lock(mutex_); + std::lock_guard lock(mutex_); callbacks_[easy] = std::move(callback); headers_[easy] = headers; } - CURLMcode rc = curl_multi_add_handle(multi_handle_, easy); - if (rc != CURLM_OK) { - std::lock_guard lock(mutex_); + if (const CURLMcode rc = curl_multi_add_handle(multi_handle_.get(), easy); + rc != CURLM_OK) { + std::lock_guard lock(mutex_); callbacks_.erase(easy); // Free headers on error - auto header_it = headers_.find(easy); - if (header_it != headers_.end() && header_it->second) { + if (const auto header_it = headers_.find(easy); + header_it != headers_.end() && header_it->second) { curl_slist_free_all(header_it->second); } headers_.erase(easy); std::cerr << "Failed to add handle to multi: " - << curl_multi_strerror(rc) << std::endl; + << curl_multi_strerror(rc) << std::endl; } } void CurlMultiManager::remove_handle(CURL* easy) { - curl_multi_remove_handle(multi_handle_, easy); + curl_multi_remove_handle(multi_handle_.get(), easy); - std::lock_guard lock(mutex_); + std::lock_guard lock(mutex_); callbacks_.erase(easy); // Free headers if they exist - auto header_it = headers_.find(easy); - if (header_it != headers_.end() && header_it->second) { + if (const auto header_it = headers_.find(easy); + header_it != headers_.end() && header_it->second) { curl_slist_free_all(header_it->second); } headers_.erase(easy); } -int CurlMultiManager::socket_callback(CURL* easy, curl_socket_t s, int what, - void* userp, void* socketp) { +int CurlMultiManager::socket_callback(CURL* easy, + curl_socket_t s, + int what, + void* userp, + void* socketp) { auto* manager = static_cast(userp); - std::lock_guard lock(manager->mutex_); + std::lock_guard lock(manager->mutex_); if (what == CURL_POLL_REMOVE) { // Remove socket from managed container - auto it = manager->sockets_.find(s); - if (it != manager->sockets_.end()) { - manager->stop_socket_monitor(&it->second); + if (const auto it = manager->sockets_.find(s); + it != manager->sockets_.end()) { manager->sockets_.erase(it); } } else { // Add or update socket in managed container - auto [it, inserted] = manager->sockets_.try_emplace(s, SocketInfo{s, nullptr, 0}); + auto [it, inserted] = manager->sockets_.try_emplace( + s, SocketInfo{s, nullptr, 0}); manager->start_socket_monitor(&it->second, what); } return 0; } -int CurlMultiManager::timer_callback(CURLM* multi, long timeout_ms, void* userp) { +int CurlMultiManager::timer_callback(CURLM* multi, + long timeout_ms, + void* userp) { auto* manager = static_cast(userp); // Cancel any existing timer @@ -141,33 +126,36 @@ int CurlMultiManager::timer_callback(CURLM* multi, long timeout_ms, void* userp) manager->timer_.expires_after(std::chrono::milliseconds(timeout_ms)); manager->timer_.async_wait([weak_self = manager->weak_from_this()]( const boost::system::error_code& ec) { - if (!ec) { - if (auto self = weak_self.lock()) { - self->handle_timeout(); + if (!ec) { + if (const auto self = weak_self.lock()) { + self->handle_timeout(); + } } - } - }); + }); } else if (timeout_ms == 0) { // Call socket_action immediately - boost::asio::post(manager->executor_, [weak_self = manager->weak_from_this()]() { - if (auto self = weak_self.lock()) { - self->handle_timeout(); - } - }); + boost::asio::post(manager->executor_, + [weak_self = manager->weak_from_this()]() { + if (const auto self = weak_self.lock()) { + self->handle_timeout(); + } + }); } // If timeout_ms < 0, no timeout (delete timer) return 0; } -void CurlMultiManager::handle_socket_action(curl_socket_t s, int event_bitmask) { +void CurlMultiManager::handle_socket_action(curl_socket_t s, + const int event_bitmask) { int running_handles = 0; - CURLMcode rc = curl_multi_socket_action(multi_handle_, s, event_bitmask, - &running_handles); + const CURLMcode rc = curl_multi_socket_action(multi_handle_.get(), s, + event_bitmask, + &running_handles); if (rc != CURLM_OK) { std::cerr << "curl_multi_socket_action failed: " - << curl_multi_strerror(rc) << std::endl; + << curl_multi_strerror(rc) << std::endl; } check_multi_info(); @@ -185,7 +173,7 @@ void CurlMultiManager::check_multi_info() { int msgs_in_queue; CURLMsg* msg; - while ((msg = curl_multi_info_read(multi_handle_, &msgs_in_queue))) { + while ((msg = curl_multi_info_read(multi_handle_.get(), &msgs_in_queue))) { if (msg->msg == CURLMSG_DONE) { CURL* easy = msg->easy_handle; CURLcode result = msg->data.result; @@ -193,23 +181,22 @@ void CurlMultiManager::check_multi_info() { CompletionCallback callback; curl_slist* headers = nullptr; { - std::lock_guard lock(mutex_); - auto it = callbacks_.find(easy); - if (it != callbacks_.end()) { + std::lock_guard lock(mutex_); + if (auto it = callbacks_.find(easy); it != callbacks_.end()) { callback = std::move(it->second); callbacks_.erase(it); } // Get and remove headers - auto header_it = headers_.find(easy); - if (header_it != headers_.end()) { + if (auto header_it = headers_.find(easy); + header_it != headers_.end()) { headers = header_it->second; headers_.erase(header_it); } } // Remove from multi handle - curl_multi_remove_handle(multi_handle_, easy); + curl_multi_remove_handle(multi_handle_.get(), easy); // Free headers if (headers) { @@ -219,15 +206,16 @@ void CurlMultiManager::check_multi_info() { // Invoke completion callback if (callback) { boost::asio::post(executor_, [callback = std::move(callback), - easy, result]() { - callback(easy, result); - }); + easy, result]() { + callback(easy, result); + }); } } } } -void CurlMultiManager::start_socket_monitor(SocketInfo* socket_info, int action) { +void CurlMultiManager::start_socket_monitor(SocketInfo* socket_info, + const int action) { if (!socket_info->descriptor) { // Create descriptor for this socket socket_info->descriptor = std::make_shared< @@ -236,7 +224,7 @@ void CurlMultiManager::start_socket_monitor(SocketInfo* socket_info, int action) } // Check if action has changed - bool action_changed = (socket_info->action != action); + const bool action_changed = (socket_info->action != action); socket_info->action = action; auto weak_self = weak_from_this(); @@ -247,40 +235,46 @@ void CurlMultiManager::start_socket_monitor(SocketInfo* socket_info, int action) // Only create new handler if we don't have one or if action changed if (!socket_info->read_handler || action_changed) { // Use weak_ptr to safely detect when descriptor is deleted - std::weak_ptr weak_descriptor = socket_info->descriptor; + std::weak_ptr weak_descriptor + = socket_info->descriptor; // Create and store handler in SocketInfo to keep it alive // Use weak_ptr in capture to avoid circular reference - socket_info->read_handler = std::make_shared>(); - std::weak_ptr> weak_read_handler = socket_info->read_handler; - *socket_info->read_handler = [weak_self, sockfd, weak_descriptor, weak_read_handler]() { - // Check if manager and descriptor are still valid - auto self = weak_self.lock(); - auto descriptor = weak_descriptor.lock(); - if (!self || !descriptor) { - return; - } + socket_info->read_handler = std::make_shared>(); + std::weak_ptr> weak_read_handler = socket_info + ->read_handler; + *socket_info->read_handler = [weak_self, sockfd, weak_descriptor, + weak_read_handler]() { + // Check if manager and descriptor are still valid + const auto self = weak_self.lock(); + const auto descriptor = weak_descriptor.lock(); + if (!self || !descriptor) { + return; + } + + descriptor->async_wait( + boost::asio::posix::stream_descriptor::wait_read, + [weak_self, sockfd, weak_descriptor, weak_read_handler]( + const boost::system::error_code& ec) { + // If operation was canceled or had an error, don't re-register + if (ec) { + return; + } + + if (const auto self = weak_self.lock()) { + self->handle_socket_action( + sockfd, CURL_CSELECT_IN); - descriptor->async_wait( - boost::asio::posix::stream_descriptor::wait_read, - [weak_self, sockfd, weak_descriptor, weak_read_handler](const boost::system::error_code& ec) { - // If operation was canceled or had an error, don't re-register - if (ec) { - return; - } - - if (auto self = weak_self.lock()) { - self->handle_socket_action(sockfd, CURL_CSELECT_IN); - - // Always try to re-register for continuous monitoring - // The validity check at the top of read_handler will stop it if needed - if (auto handler = weak_read_handler.lock()) { - (*handler)(); // Recursive call + // Always try to re-register for continuous monitoring + // The validity check at the top of read_handler will stop it if needed + if (const auto handler = weak_read_handler.lock()) { + (*handler)(); // Recursive call + } } - } - }); - }; - (*socket_info->read_handler)(); // Initial call + }); + }; + (*socket_info->read_handler)(); // Initial call } } @@ -289,55 +283,49 @@ void CurlMultiManager::start_socket_monitor(SocketInfo* socket_info, int action) // Only create new handler if we don't have one or if action changed if (!socket_info->write_handler || action_changed) { // Use weak_ptr to safely detect when descriptor is deleted - std::weak_ptr weak_descriptor = socket_info->descriptor; + std::weak_ptr weak_descriptor + = socket_info->descriptor; // Create and store handler in SocketInfo to keep it alive // Use weak_ptr in capture to avoid circular reference - socket_info->write_handler = std::make_shared>(); - std::weak_ptr> weak_write_handler = socket_info->write_handler; - *socket_info->write_handler = [weak_self, sockfd, weak_descriptor, weak_write_handler]() { - // Check if manager and descriptor are still valid - auto self = weak_self.lock(); - auto descriptor = weak_descriptor.lock(); - if (!self || !descriptor) { - return; - } + socket_info->write_handler = std::make_shared>(); + std::weak_ptr> weak_write_handler = + socket_info->write_handler; + *socket_info->write_handler = [weak_self, sockfd, weak_descriptor, + weak_write_handler]() { + // Check if manager and descriptor are still valid + const auto self = weak_self.lock(); + const auto descriptor = weak_descriptor.lock(); + if (!self || !descriptor) { + return; + } + + descriptor->async_wait( + boost::asio::posix::stream_descriptor::wait_write, + [weak_self, sockfd, weak_descriptor, weak_write_handler + ](const boost::system::error_code& ec) { + // If operation was canceled or had an error, don't re-register + if (ec) { + return; + } - descriptor->async_wait( - boost::asio::posix::stream_descriptor::wait_write, - [weak_self, sockfd, weak_descriptor, weak_write_handler](const boost::system::error_code& ec) { - // If operation was canceled or had an error, don't re-register - if (ec) { - return; - } - - if (auto self = weak_self.lock()) { - self->handle_socket_action(sockfd, CURL_CSELECT_OUT); - - // Always try to re-register for continuous monitoring - // The validity check at the top of write_handler will stop it if needed - if (auto handler = weak_write_handler.lock()) { - (*handler)(); // Recursive call + if (const auto self = weak_self.lock()) { + self->handle_socket_action( + sockfd, CURL_CSELECT_OUT); + + // Always try to re-register for continuous monitoring + // The validity check at the top of write_handler will stop it if needed + if (const auto handler = weak_write_handler.lock()) { + (*handler)(); // Recursive call + } } - } - }); - }; - (*socket_info->write_handler)(); // Initial call + }); + }; + (*socket_info->write_handler)(); // Initial call } } } - -void CurlMultiManager::stop_socket_monitor(SocketInfo* socket_info) { - if (socket_info->descriptor) { - socket_info->descriptor->cancel(); - socket_info->descriptor->release(); - socket_info->descriptor.reset(); - } - // Clear handlers to break any weak_ptr references - socket_info->read_handler.reset(); - socket_info->write_handler.reset(); -} - } // namespace launchdarkly::network -#endif // LD_CURL_NETWORKING +#endif // LD_CURL_NETWORKING \ No newline at end of file From 67a782d5b34c2c3ab5f23626ccbbf4e93981081d Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 17 Oct 2025 09:04:52 -0700 Subject: [PATCH 65/90] Networking build must always use curl. --- .github/workflows/networking.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/networking.yml b/.github/workflows/networking.yml index fcc5a9b59..c0909f2ae 100644 --- a/.github/workflows/networking.yml +++ b/.github/workflows/networking.yml @@ -18,3 +18,4 @@ jobs: - uses: ./.github/actions/ci with: cmake_target: launchdarkly-cpp-networking + use_curl: 'true' From f330e97107a9a95e686516855a44c7b726a7476a Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 17 Oct 2025 09:09:34 -0700 Subject: [PATCH 66/90] Disable tests for networking component. --- .github/workflows/networking.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/networking.yml b/.github/workflows/networking.yml index c0909f2ae..23fe45e1f 100644 --- a/.github/workflows/networking.yml +++ b/.github/workflows/networking.yml @@ -19,3 +19,5 @@ jobs: with: cmake_target: launchdarkly-cpp-networking use_curl: 'true' + # Project doesn't have tests at this time. + run_tests: 'false' From ff3a07b63d98f7bbe1e38602889203194ab17b99 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 17 Oct 2025 10:35:17 -0700 Subject: [PATCH 67/90] Consolidate parsing. --- libs/server-sent-events/src/curl_client.cpp | 143 ++------------------ libs/server-sent-events/src/curl_client.hpp | 25 ++-- libs/server-sent-events/src/parser.hpp | 31 ++++- 3 files changed, 55 insertions(+), 144 deletions(-) diff --git a/libs/server-sent-events/src/curl_client.cpp b/libs/server-sent-events/src/curl_client.cpp index 343db9d87..51d42d3ff 100644 --- a/libs/server-sent-events/src/curl_client.cpp +++ b/libs/server-sent-events/src/curl_client.cpp @@ -232,10 +232,9 @@ bool CurlClient::SetupCurlOptions(CURL* curl, headers = curl_slist_append(headers, header.c_str()); } - // Add Last-Event-ID if we have one + // Add Last-Event-ID if we have one from previous connection if (context.last_event_id && !context.last_event_id->empty()) { - std::string last_event_header = - "Last-Event-ID: " + *context.last_event_id; + std::string last_event_header = "Last-Event-ID: " + *context.last_event_id; headers = curl_slist_append(headers, last_event_header.c_str()); } @@ -322,7 +321,7 @@ int CurlClient::ProgressCallback(void* clientp, context->last_progress_time = now; } else { // No new data - check if we've exceeded the timeout - auto elapsed = std::chrono::duration_cast< + const auto elapsed = std::chrono::duration_cast< std::chrono::milliseconds>( now - context->last_progress_time); @@ -369,128 +368,17 @@ size_t CurlClient::WriteCallback(const char* data, return 0; // Abort the transfer } - // Parse SSE data - std::string_view body(data, total_size); - - // Parse stream into lines - size_t i = 0; - while (i < body.size()) { - // Find next line delimiter - const size_t delimiter_pos = body.find_first_of("\r\n", i); - const size_t append_size = (delimiter_pos == std::string::npos) - ? (body.size() - i) - : (delimiter_pos - i); - - // Append to buffered line - if (context->buffered_line.has_value()) { - context->buffered_line->append(body.substr(i, append_size)); - } else { - context->buffered_line = std::string(body.substr(i, append_size)); - } - - i += append_size; - - if (i >= body.size()) { - break; - } - - // Handle line delimiters - if (body[i] == '\r') { - context->complete_lines.push_back(*context->buffered_line); - context->buffered_line.reset(); - context->begin_CR = true; - i++; - } else if (body[i] == '\n') { - if (context->begin_CR) { - context->begin_CR = false; - } else { - context->complete_lines.push_back(*context->buffered_line); - context->buffered_line.reset(); - } - i++; - } - } - - // Parse completed lines into events - while (!context->complete_lines.empty()) { - std::string line = std::move(context->complete_lines.front()); - context->complete_lines.pop_front(); - - if (line.empty()) { - // Empty line indicates end of event - if (context->current_event) { - // Trim trailing newline from data - if (!context->current_event->data.empty() && - context->current_event->data.back() == '\n') { - context->current_event->data.pop_back(); - } - - // Update last_event_id_ only when dispatching a completed event - if (context->current_event->id) { - context->last_event_id = context->current_event->id; - } - - // Dispatch event on executor thread - auto event_data = context->current_event->data; - auto event_type = context->current_event->type.empty() - ? "message" - : context->current_event->type; - auto event_id = context->current_event->id; - context->receive(Event( - std::move(event_type), - std::move(event_data), - std::move(event_id))); - - context->current_event.reset(); - } - continue; - } - - // Parse field - const size_t colon_pos = line.find(':'); - if (colon_pos == 0) { - // Comment line, dispatch it - std::string comment = line.substr(1); - - context->receive(Event("comment", comment)); - continue; - } - - std::string field_name; - std::string field_value; - - if (colon_pos == std::string::npos) { - field_name = line; - field_value = ""; - } else { - field_name = line.substr(0, colon_pos); - field_value = line.substr(colon_pos + 1); - - // Remove leading space from value if present - if (!field_value.empty() && field_value[0] == ' ') { - field_value = field_value.substr(1); - } - } - - // Initialize event if needed - if (!context->current_event) { - context->current_event.emplace(detail::Event{}); - context->current_event->id = context->last_event_id; + // Set up the event receiver callback for the parser + context->parser_body->on_event([context](Event event) { + // Track last event ID for reconnection + if (event.id()) { + context->last_event_id = event.id(); } + context->receive(std::move(event)); + }); - // Handle field - if (field_name == "event") { - context->current_event->type = field_value; - } else if (field_name == "data") { - context->current_event->data += field_value; - context->current_event->data += '\n'; - } else if (field_name == "id") { - if (field_value.find('\0') == std::string::npos) { - context->current_event->id = field_value; - } - } - // retry field is ignored for now - } + const std::string_view data_view(data, total_size); + context->parser_reader->put(data_view); return total_size; } @@ -525,11 +413,8 @@ void CurlClient::PerformRequestWithMulti( return; } - // Clear parser state for new connection - context->buffered_line.reset(); - context->complete_lines.clear(); - context->current_event.reset(); - context->begin_CR = false; + // Initialize parser for new connection (last_event_id is tracked separately) + context->init_parser(); CURL* curl = curl_easy_init(); if (!curl) { diff --git a/libs/server-sent-events/src/curl_client.hpp b/libs/server-sent-events/src/curl_client.hpp index d045b0652..a493c0f47 100644 --- a/libs/server-sent-events/src/curl_client.hpp +++ b/libs/server-sent-events/src/curl_client.hpp @@ -73,19 +73,18 @@ class CurlClient final : public Client, std::optional callbacks_; public: - // SSE parser state - std::optional buffered_line; - std::deque complete_lines; - bool begin_CR; + // SSE parser using common parser from parser.hpp + using ParserBody = detail::EventBody>; + std::unique_ptr parser_body; + std::unique_ptr parser_reader; + + // Track last event ID for reconnection (separate from parser state) + std::optional last_event_id; // Progress tracking for read timeout std::chrono::steady_clock::time_point last_progress_time; curl_off_t last_download_amount; - - std::optional last_event_id; - std::optional current_event; - const http::request req; const std::string url; const std::optional connect_timeout; @@ -182,11 +181,7 @@ class CurlClient final : public Client, bool skip_verify_peer ) : shutting_down_(false), curl_socket_(CURL_SOCKET_BAD), - buffered_line(std::nullopt), - begin_CR(false), last_download_amount(0), - last_event_id(std::nullopt), - current_event(std::nullopt), req(std::move(req)), url(std::move(url)), connect_timeout(connect_timeout), @@ -196,6 +191,12 @@ class CurlClient final : public Client, proxy_url(std::move(proxy_url)), skip_verify_peer(skip_verify_peer) { } + + void init_parser() { + parser_body = std::make_unique(); + parser_reader = std::make_unique(*parser_body); + parser_reader->init(); + } }; public: diff --git a/libs/server-sent-events/src/parser.hpp b/libs/server-sent-events/src/parser.hpp index 375365dad..f49300f0c 100644 --- a/libs/server-sent-events/src/parser.hpp +++ b/libs/server-sent-events/src/parser.hpp @@ -62,6 +62,16 @@ struct EventBody::reader { std::optional event_; public: + // Constructor for standalone use (curl_client) - no Boost types required + explicit reader(value_type& body) + : body_(body), + buffered_line_(), + complete_lines_(), + begin_CR_(false), + event_() { + } + + // Constructor for Boost Beast HTTP body reader (FoxyClient) template reader(http::header& h, value_type& body) : body_(body), @@ -87,6 +97,11 @@ struct EventBody::reader { ec = {}; } + // Simplified init for standalone use - no Boost types required + void init() { + // Nothing to initialize + } + /** * Store buffers. * This is called zero or more times with parsed body octets. @@ -104,6 +119,16 @@ struct EventBody::reader { return buffer_bytes(buffers); } + /** + * Simplified put for standalone use - no Boost types required. + * Feed data into the parser. This can be called multiple times as data arrives. + * @param data The data to parse + */ + void put(std::string_view data) { + parse_stream(data); + parse_events(); + } + /** * Called when the body is complete. * @param ec Set to the error, if any occurred. @@ -124,20 +149,20 @@ struct EventBody::reader { // Appends the body to the buffered line until reaching any of the // characters specified within the search parameter. The search parameter is // treated as an array of search characters, not as a single token. - size_t append_up_to(boost::string_view body, std::string const& search) { + size_t append_up_to(std::string_view body, std::string const& search) { std::size_t index = body.find_first_of(search); if (index != std::string::npos) { body.remove_suffix(body.size() - index); } if (buffered_line_.has_value()) { - buffered_line_->append(body.to_string()); + buffered_line_->append(body); } else { buffered_line_ = std::string{body}; } return index == std::string::npos ? body.size() : index; } - void parse_stream(boost::string_view body) { + void parse_stream(std::string_view body) { size_t i = 0; while (i < body.size()) { i += this->append_up_to(body.substr(i, body.length() - i), "\r\n"); From 2ca2763d27eb8e3526baa5d1a1494779035fab4c Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 17 Oct 2025 13:37:59 -0700 Subject: [PATCH 68/90] Use correct descriptor on windows. --- .../network/curl_multi_manager.hpp | 16 +- libs/networking/src/curl_multi_manager.cpp | 143 +++++++++--------- 2 files changed, 83 insertions(+), 76 deletions(-) diff --git a/libs/networking/include/launchdarkly/network/curl_multi_manager.hpp b/libs/networking/include/launchdarkly/network/curl_multi_manager.hpp index 9529d0769..ce18213be 100644 --- a/libs/networking/include/launchdarkly/network/curl_multi_manager.hpp +++ b/libs/networking/include/launchdarkly/network/curl_multi_manager.hpp @@ -4,7 +4,11 @@ #include #include +#ifdef _WIN32 +#include +#else #include +#endif #include #include @@ -13,6 +17,14 @@ #include namespace launchdarkly::network { + +// Platform-specific socket handle type +#ifdef _WIN32 +using SocketHandle = boost::asio::windows::stream_handle; +#else +using SocketHandle = boost::asio::posix::stream_descriptor; +#endif + /** * Manages CURL multi interface integrated with ASIO event loop. * @@ -23,7 +35,7 @@ namespace launchdarkly::network { * * Key features: * - Non-blocking I/O using curl_multi_socket_action - * - Socket monitoring via ASIO stream_descriptor + * - Socket monitoring via ASIO (posix::stream_descriptor on POSIX, windows::stream_handle on Windows) * - Timer integration with ASIO steady_timer * - Thread-safe operation on ASIO executor */ @@ -91,7 +103,7 @@ class CurlMultiManager : public std::enable_shared_from_this { // Per-socket data struct SocketInfo { curl_socket_t sockfd; - std::shared_ptr descriptor; + std::shared_ptr handle; int action{0}; // CURL_POLL_IN, CURL_POLL_OUT, etc. // Keep handlers alive - we own them and they only capture weak_ptr to avoid circular refs std::shared_ptr> read_handler; diff --git a/libs/networking/src/curl_multi_manager.cpp b/libs/networking/src/curl_multi_manager.cpp index 2a13a7757..3f348ea87 100644 --- a/libs/networking/src/curl_multi_manager.cpp +++ b/libs/networking/src/curl_multi_manager.cpp @@ -216,11 +216,16 @@ void CurlMultiManager::check_multi_info() { void CurlMultiManager::start_socket_monitor(SocketInfo* socket_info, const int action) { - if (!socket_info->descriptor) { - // Create descriptor for this socket - socket_info->descriptor = std::make_shared< - boost::asio::posix::stream_descriptor>(executor_); - socket_info->descriptor->assign(socket_info->sockfd); + if (!socket_info->handle) { + // Create handle for this socket + socket_info->handle = std::make_shared(executor_); +#ifdef _WIN32 + // On Windows, we need to cast the socket to HANDLE for stream_handle + socket_info->handle->assign(reinterpret_cast(socket_info->sockfd)); +#else + // On POSIX, we can assign the socket descriptor directly + socket_info->handle->assign(socket_info->sockfd); +#endif } // Check if action has changed @@ -234,46 +239,41 @@ void CurlMultiManager::start_socket_monitor(SocketInfo* socket_info, if (action & CURL_POLL_IN) { // Only create new handler if we don't have one or if action changed if (!socket_info->read_handler || action_changed) { - // Use weak_ptr to safely detect when descriptor is deleted - std::weak_ptr weak_descriptor - = socket_info->descriptor; + // Use weak_ptr to safely detect when handle is deleted + std::weak_ptr weak_handle = socket_info->handle; // Create and store handler in SocketInfo to keep it alive // Use weak_ptr in capture to avoid circular reference - socket_info->read_handler = std::make_shared>(); - std::weak_ptr> weak_read_handler = socket_info - ->read_handler; - *socket_info->read_handler = [weak_self, sockfd, weak_descriptor, - weak_read_handler]() { - // Check if manager and descriptor are still valid - const auto self = weak_self.lock(); - const auto descriptor = weak_descriptor.lock(); - if (!self || !descriptor) { - return; - } + socket_info->read_handler = std::make_shared>(); + std::weak_ptr> weak_read_handler = socket_info->read_handler; + *socket_info->read_handler = [weak_self, sockfd, weak_handle, weak_read_handler]() { + // Check if manager and handle are still valid + const auto self = weak_self.lock(); + const auto handle = weak_handle.lock(); + if (!self || !handle) { + return; + } - descriptor->async_wait( - boost::asio::posix::stream_descriptor::wait_read, - [weak_self, sockfd, weak_descriptor, weak_read_handler]( + handle->async_wait( + SocketHandle::wait_read, + [weak_self, sockfd, weak_handle, weak_read_handler]( const boost::system::error_code& ec) { - // If operation was canceled or had an error, don't re-register - if (ec) { - return; - } - - if (const auto self = weak_self.lock()) { - self->handle_socket_action( - sockfd, CURL_CSELECT_IN); - - // Always try to re-register for continuous monitoring - // The validity check at the top of read_handler will stop it if needed - if (const auto handler = weak_read_handler.lock()) { - (*handler)(); // Recursive call - } + // If operation was canceled or had an error, don't re-register + if (ec) { + return; + } + + if (const auto self = weak_self.lock()) { + self->handle_socket_action(sockfd, CURL_CSELECT_IN); + + // Always try to re-register for continuous monitoring + // The validity check at the top of read_handler will stop it if needed + if (const auto handler = weak_read_handler.lock()) { + (*handler)(); // Recursive call } - }); - }; + } + }); + }; (*socket_info->read_handler)(); // Initial call } } @@ -282,46 +282,41 @@ void CurlMultiManager::start_socket_monitor(SocketInfo* socket_info, if (action & CURL_POLL_OUT) { // Only create new handler if we don't have one or if action changed if (!socket_info->write_handler || action_changed) { - // Use weak_ptr to safely detect when descriptor is deleted - std::weak_ptr weak_descriptor - = socket_info->descriptor; + // Use weak_ptr to safely detect when handle is deleted + std::weak_ptr weak_handle = socket_info->handle; // Create and store handler in SocketInfo to keep it alive // Use weak_ptr in capture to avoid circular reference - socket_info->write_handler = std::make_shared>(); - std::weak_ptr> weak_write_handler = - socket_info->write_handler; - *socket_info->write_handler = [weak_self, sockfd, weak_descriptor, - weak_write_handler]() { - // Check if manager and descriptor are still valid - const auto self = weak_self.lock(); - const auto descriptor = weak_descriptor.lock(); - if (!self || !descriptor) { - return; - } - - descriptor->async_wait( - boost::asio::posix::stream_descriptor::wait_write, - [weak_self, sockfd, weak_descriptor, weak_write_handler - ](const boost::system::error_code& ec) { - // If operation was canceled or had an error, don't re-register - if (ec) { - return; - } - - if (const auto self = weak_self.lock()) { - self->handle_socket_action( - sockfd, CURL_CSELECT_OUT); + socket_info->write_handler = std::make_shared>(); + std::weak_ptr> weak_write_handler = socket_info->write_handler; + *socket_info->write_handler = [weak_self, sockfd, weak_handle, weak_write_handler]() { + // Check if manager and handle are still valid + const auto self = weak_self.lock(); + const auto handle = weak_handle.lock(); + if (!self || !handle) { + return; + } - // Always try to re-register for continuous monitoring - // The validity check at the top of write_handler will stop it if needed - if (const auto handler = weak_write_handler.lock()) { - (*handler)(); // Recursive call - } + handle->async_wait( + SocketHandle::wait_write, + [weak_self, sockfd, weak_handle, weak_write_handler]( + const boost::system::error_code& ec) { + // If operation was canceled or had an error, don't re-register + if (ec) { + return; + } + + if (const auto self = weak_self.lock()) { + self->handle_socket_action(sockfd, CURL_CSELECT_OUT); + + // Always try to re-register for continuous monitoring + // The validity check at the top of write_handler will stop it if needed + if (const auto handler = weak_write_handler.lock()) { + (*handler)(); // Recursive call } - }); - }; + } + }); + }; (*socket_info->write_handler)(); // Initial call } } From d54c97352168a866ad5ed37bbc239b638dea61e4 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 17 Oct 2025 14:02:35 -0700 Subject: [PATCH 69/90] Socket abstraction. --- .../network/curl_multi_manager.hpp | 16 +++--------- libs/networking/src/curl_multi_manager.cpp | 25 +++++++++++-------- 2 files changed, 19 insertions(+), 22 deletions(-) diff --git a/libs/networking/include/launchdarkly/network/curl_multi_manager.hpp b/libs/networking/include/launchdarkly/network/curl_multi_manager.hpp index ce18213be..a6ab9aadc 100644 --- a/libs/networking/include/launchdarkly/network/curl_multi_manager.hpp +++ b/libs/networking/include/launchdarkly/network/curl_multi_manager.hpp @@ -4,11 +4,7 @@ #include #include -#ifdef _WIN32 -#include -#else -#include -#endif +#include #include #include @@ -18,12 +14,8 @@ namespace launchdarkly::network { -// Platform-specific socket handle type -#ifdef _WIN32 -using SocketHandle = boost::asio::windows::stream_handle; -#else -using SocketHandle = boost::asio::posix::stream_descriptor; -#endif +// Use tcp::socket for cross-platform socket operations +using SocketHandle = boost::asio::ip::tcp::socket; /** * Manages CURL multi interface integrated with ASIO event loop. @@ -35,7 +27,7 @@ using SocketHandle = boost::asio::posix::stream_descriptor; * * Key features: * - Non-blocking I/O using curl_multi_socket_action - * - Socket monitoring via ASIO (posix::stream_descriptor on POSIX, windows::stream_handle on Windows) + * - Cross-platform socket monitoring via ASIO tcp::socket * - Timer integration with ASIO steady_timer * - Thread-safe operation on ASIO executor */ diff --git a/libs/networking/src/curl_multi_manager.cpp b/libs/networking/src/curl_multi_manager.cpp index 3f348ea87..5088283d6 100644 --- a/libs/networking/src/curl_multi_manager.cpp +++ b/libs/networking/src/curl_multi_manager.cpp @@ -217,15 +217,20 @@ void CurlMultiManager::check_multi_info() { void CurlMultiManager::start_socket_monitor(SocketInfo* socket_info, const int action) { if (!socket_info->handle) { - // Create handle for this socket + // Create tcp::socket and assign the native socket handle + // This works cross-platform (Windows and POSIX) socket_info->handle = std::make_shared(executor_); -#ifdef _WIN32 - // On Windows, we need to cast the socket to HANDLE for stream_handle - socket_info->handle->assign(reinterpret_cast(socket_info->sockfd)); -#else - // On POSIX, we can assign the socket descriptor directly - socket_info->handle->assign(socket_info->sockfd); -#endif + + // Assign the CURL socket to the ASIO socket + // tcp::socket::assign works with native socket handles on both platforms + boost::system::error_code ec; + socket_info->handle->assign(boost::asio::ip::tcp::v4(), socket_info->sockfd, ec); + + if (ec) { + std::cerr << "Failed to assign socket: " << ec.message() << std::endl; + socket_info->handle.reset(); + return; + } } // Check if action has changed @@ -255,7 +260,7 @@ void CurlMultiManager::start_socket_monitor(SocketInfo* socket_info, } handle->async_wait( - SocketHandle::wait_read, + boost::asio::ip::tcp::socket::wait_read, [weak_self, sockfd, weak_handle, weak_read_handler]( const boost::system::error_code& ec) { // If operation was canceled or had an error, don't re-register @@ -298,7 +303,7 @@ void CurlMultiManager::start_socket_monitor(SocketInfo* socket_info, } handle->async_wait( - SocketHandle::wait_write, + boost::asio::ip::tcp::socket::wait_write, [weak_self, sockfd, weak_handle, weak_write_handler]( const boost::system::error_code& ec) { // If operation was canceled or had an error, don't re-register From 0dfe280a589665698ac91cfe01b1b9f841c4c53f Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 17 Oct 2025 14:17:51 -0700 Subject: [PATCH 70/90] ASIO header only --- libs/networking/src/CMakeLists.txt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/libs/networking/src/CMakeLists.txt b/libs/networking/src/CMakeLists.txt index b1a21abae..975f8e9c0 100644 --- a/libs/networking/src/CMakeLists.txt +++ b/libs/networking/src/CMakeLists.txt @@ -31,7 +31,11 @@ target_include_directories(${LIBNAME} # Minimum C++ standard needed for consuming the public API is C++17. target_compile_features(${LIBNAME} PUBLIC cxx_std_17) -target_compile_definitions(${LIBNAME} PUBLIC LD_CURL_NETWORKING) +target_compile_definitions(${LIBNAME} + PUBLIC + LD_CURL_NETWORKING + BOOST_ASIO_HEADER_ONLY +) # Using PUBLIC_HEADERS would flatten the include. # This will preserve it, but dependencies must do the same. From e7f6564f31646a20e22cad4bf599263aa121608a Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 17 Oct 2025 15:34:18 -0700 Subject: [PATCH 71/90] Incremental memory management improvements. --- libs/internal/src/network/curl_requester.cpp | 55 +++--- .../network/curl_multi_manager.hpp | 3 +- libs/networking/src/curl_multi_manager.cpp | 162 ++++++++++-------- libs/server-sent-events/src/curl_client.cpp | 7 +- .../tests/curl_client_test.cpp | 13 +- 5 files changed, 121 insertions(+), 119 deletions(-) diff --git a/libs/internal/src/network/curl_requester.cpp b/libs/internal/src/network/curl_requester.cpp index bba78242c..70eaddf65 100644 --- a/libs/internal/src/network/curl_requester.cpp +++ b/libs/internal/src/network/curl_requester.cpp @@ -93,7 +93,7 @@ void CurlRequester::PerformRequestWithMulti(std::shared_ptr mu return; } - CURL* curl = curl_easy_init(); + std::shared_ptrcurl (curl_easy_init(), curl_easy_cleanup); if (!curl) { cb(HttpResult(kErrorCurlInit)); return; @@ -102,23 +102,14 @@ void CurlRequester::PerformRequestWithMulti(std::shared_ptr mu // Create context to hold data for this request // This will be cleaned up in the completion callback struct RequestContext { - CURL* curl; std::string url; std::string body; // Keep body alive std::string response_body; HttpResult::HeadersType response_headers; std::function callback; - - ~RequestContext() { - // Headers are managed by CurlMultiManager - if (curl) { - curl_easy_cleanup(curl); - } - } }; auto ctx = std::make_shared(); - ctx->curl = curl; ctx->callback = std::move(cb); // Headers will be managed by CurlMultiManager @@ -144,7 +135,7 @@ void CurlRequester::PerformRequestWithMulti(std::shared_ptr mu ctx->url = request.Url(); // Set URL - CURL_SETOPT_CHECK(curl, CURLOPT_URL, ctx->url.c_str()); + CURL_SETOPT_CHECK(curl.get(), CURLOPT_URL, ctx->url.c_str()); // Set HTTP method if (request.Method() == HttpMethod::kPost) { @@ -152,20 +143,20 @@ void CurlRequester::PerformRequestWithMulti(std::shared_ptr mu // Passing 1 enables this flag. // This will also set a content type, but the headers for the request // should override that with the correct value. - CURL_SETOPT_CHECK(curl, CURLOPT_POST, 1L); + CURL_SETOPT_CHECK(curl.get(), CURLOPT_POST, 1L); } else if (request.Method() == HttpMethod::kPut) { - CURL_SETOPT_CHECK(curl, CURLOPT_CUSTOMREQUEST, kHttpMethodPut); + CURL_SETOPT_CHECK(curl.get(), CURLOPT_CUSTOMREQUEST, kHttpMethodPut); } else if (request.Method() == HttpMethod::kReport) { - CURL_SETOPT_CHECK(curl, CURLOPT_CUSTOMREQUEST, kHttpMethodReport); + CURL_SETOPT_CHECK(curl.get(), CURLOPT_CUSTOMREQUEST, kHttpMethodReport); } else if (request.Method() == HttpMethod::kGet) { - CURL_SETOPT_CHECK(curl, CURLOPT_HTTPGET, 1L); + CURL_SETOPT_CHECK(curl.get(), CURLOPT_HTTPGET, 1L); } // Set request body if present if (request.Body().has_value()) { ctx->body = request.Body().value(); - CURL_SETOPT_CHECK(curl, CURLOPT_POSTFIELDS, ctx->body.c_str()); - CURL_SETOPT_CHECK(curl, CURLOPT_POSTFIELDSIZE, ctx->body.size()); + CURL_SETOPT_CHECK(curl.get(), CURLOPT_POSTFIELDS, ctx->body.c_str()); + CURL_SETOPT_CHECK(curl.get(), CURLOPT_POSTFIELDSIZE, ctx->body.size()); } // Set headers @@ -183,31 +174,31 @@ void CurlRequester::PerformRequestWithMulti(std::shared_ptr mu headers = appendResult; } if (headers) { - CURL_SETOPT_CHECK(curl, CURLOPT_HTTPHEADER, headers); + CURL_SETOPT_CHECK(curl.get(), CURLOPT_HTTPHEADER, headers); } // Set timeouts with millisecond precision const long connect_timeout_ms = request.Properties().ConnectTimeout().count(); const long response_timeout_ms = request.Properties().ResponseTimeout().count(); - CURL_SETOPT_CHECK(curl, CURLOPT_CONNECTTIMEOUT_MS, connect_timeout_ms > 0 ? connect_timeout_ms : 30000L); - CURL_SETOPT_CHECK(curl, CURLOPT_TIMEOUT_MS, response_timeout_ms > 0 ? response_timeout_ms : 60000L); + CURL_SETOPT_CHECK(curl.get(), CURLOPT_CONNECTTIMEOUT_MS, connect_timeout_ms > 0 ? connect_timeout_ms : 30000L); + CURL_SETOPT_CHECK(curl.get(), CURLOPT_TIMEOUT_MS, response_timeout_ms > 0 ? response_timeout_ms : 60000L); // Set TLS options using VerifyMode = config::shared::built::TlsOptions::VerifyMode; if (tls_options.PeerVerifyMode() == VerifyMode::kVerifyNone) { - CURL_SETOPT_CHECK(curl, CURLOPT_SSL_VERIFYPEER, 0L); - CURL_SETOPT_CHECK(curl, CURLOPT_SSL_VERIFYHOST, 0L); + CURL_SETOPT_CHECK(curl.get(), CURLOPT_SSL_VERIFYPEER, 0L); + CURL_SETOPT_CHECK(curl.get(), CURLOPT_SSL_VERIFYHOST, 0L); } else { - CURL_SETOPT_CHECK(curl, CURLOPT_SSL_VERIFYPEER, 1L); + CURL_SETOPT_CHECK(curl.get(), CURLOPT_SSL_VERIFYPEER, 1L); // 1 or 2 seem to basically be the same, but the documentation says to // use 2, and that it would default to 2. // https://curl.se/libcurl/c/CURLOPT_SSL_VERIFYHOST.html - CURL_SETOPT_CHECK(curl, CURLOPT_SSL_VERIFYHOST, 2L); + CURL_SETOPT_CHECK(curl.get(), CURLOPT_SSL_VERIFYHOST, 2L); // Set custom CA file if provided if (tls_options.CustomCAFile().has_value()) { - CURL_SETOPT_CHECK(curl, CURLOPT_CAINFO, tls_options.CustomCAFile()->c_str()); + CURL_SETOPT_CHECK(curl.get(), CURLOPT_CAINFO, tls_options.CustomCAFile()->c_str()); } } @@ -216,19 +207,19 @@ void CurlRequester::PerformRequestWithMulti(std::shared_ptr mu // Empty string explicitly disables proxy (overrides environment variables). auto const& proxy_url = request.Properties().Proxy().Url(); if (proxy_url.has_value()) { - CURL_SETOPT_CHECK(curl, CURLOPT_PROXY, proxy_url->c_str()); + CURL_SETOPT_CHECK(curl.get(), CURLOPT_PROXY, proxy_url->c_str()); } // If proxy URL is std::nullopt, CURL will use environment variables (default behavior) // Set callbacks - CURL_SETOPT_CHECK(curl, CURLOPT_WRITEFUNCTION, WriteCallback); - CURL_SETOPT_CHECK(curl, CURLOPT_WRITEDATA, &ctx->response_body); - CURL_SETOPT_CHECK(curl, CURLOPT_HEADERFUNCTION, HeaderCallback); - CURL_SETOPT_CHECK(curl, CURLOPT_HEADERDATA, &ctx->response_headers); + CURL_SETOPT_CHECK(curl.get(), CURLOPT_WRITEFUNCTION, WriteCallback); + CURL_SETOPT_CHECK(curl.get(), CURLOPT_WRITEDATA, &ctx->response_body); + CURL_SETOPT_CHECK(curl.get(), CURLOPT_HEADERFUNCTION, HeaderCallback); + CURL_SETOPT_CHECK(curl.get(), CURLOPT_HEADERDATA, &ctx->response_headers); // Follow redirects - CURL_SETOPT_CHECK(curl, CURLOPT_FOLLOWLOCATION, 1L); - CURL_SETOPT_CHECK(curl, CURLOPT_MAXREDIRS, 20L); + CURL_SETOPT_CHECK(curl.get(), CURLOPT_FOLLOWLOCATION, 1L); + CURL_SETOPT_CHECK(curl.get(), CURLOPT_MAXREDIRS, 20L); #undef CURL_SETOPT_CHECK diff --git a/libs/networking/include/launchdarkly/network/curl_multi_manager.hpp b/libs/networking/include/launchdarkly/network/curl_multi_manager.hpp index a6ab9aadc..d7fd86118 100644 --- a/libs/networking/include/launchdarkly/network/curl_multi_manager.hpp +++ b/libs/networking/include/launchdarkly/network/curl_multi_manager.hpp @@ -60,7 +60,7 @@ class CurlMultiManager : public std::enable_shared_from_this { * @param headers The curl_slist headers (will be freed automatically) * @param callback Called when the transfer completes */ - void add_handle(CURL* easy, + void add_handle(std::shared_ptr easy, curl_slist* headers, CompletionCallback callback); @@ -112,6 +112,7 @@ class CurlMultiManager : public std::enable_shared_from_this { std::mutex mutex_; std::map callbacks_; std::map headers_; + std::map> handles_; std::map sockets_; // Managed socket info int still_running_{0}; }; diff --git a/libs/networking/src/curl_multi_manager.cpp b/libs/networking/src/curl_multi_manager.cpp index 5088283d6..0be92ef5f 100644 --- a/libs/networking/src/curl_multi_manager.cpp +++ b/libs/networking/src/curl_multi_manager.cpp @@ -43,34 +43,32 @@ CurlMultiManager::~CurlMultiManager() { curl_slist_free_all(header_it->second); } - curl_easy_cleanup(easy); + // curl_easy_cleanup(easy); } } } -void CurlMultiManager::add_handle(CURL* easy, +void CurlMultiManager::add_handle(std::shared_ptr easy, curl_slist* headers, CompletionCallback callback) { - { - std::lock_guard lock(mutex_); - callbacks_[easy] = std::move(callback); - headers_[easy] = headers; - } - - if (const CURLMcode rc = curl_multi_add_handle(multi_handle_.get(), easy); + if (const CURLMcode rc = curl_multi_add_handle( + multi_handle_.get(), easy.get()); rc != CURLM_OK) { - std::lock_guard lock(mutex_); - callbacks_.erase(easy); - // Free headers on error - if (const auto header_it = headers_.find(easy); - header_it != headers_.end() && header_it->second) { - curl_slist_free_all(header_it->second); + if (headers) { + curl_slist_free_all(headers); } - headers_.erase(easy); std::cerr << "Failed to add handle to multi: " << curl_multi_strerror(rc) << std::endl; + return; + } + + { + std::lock_guard lock(mutex_); + callbacks_[easy.get()] = std::move(callback); + headers_[easy.get()] = headers; + handles_[easy.get()] = easy; } } @@ -78,14 +76,16 @@ void CurlMultiManager::remove_handle(CURL* easy) { curl_multi_remove_handle(multi_handle_.get(), easy); std::lock_guard lock(mutex_); - callbacks_.erase(easy); // Free headers if they exist if (const auto header_it = headers_.find(easy); header_it != headers_.end() && header_it->second) { curl_slist_free_all(header_it->second); } + + callbacks_.erase(easy); headers_.erase(easy); + handles_.erase(easy); } int CurlMultiManager::socket_callback(CURL* easy, @@ -182,10 +182,12 @@ void CurlMultiManager::check_multi_info() { curl_slist* headers = nullptr; { std::lock_guard lock(mutex_); + if (auto it = callbacks_.find(easy); it != callbacks_.end()) { callback = std::move(it->second); callbacks_.erase(it); } + callbacks_.erase(easy); // Get and remove headers if (auto header_it = headers_.find(easy); @@ -193,6 +195,8 @@ void CurlMultiManager::check_multi_info() { headers = header_it->second; headers_.erase(header_it); } + + handles_.erase(easy); } // Remove from multi handle @@ -224,10 +228,12 @@ void CurlMultiManager::start_socket_monitor(SocketInfo* socket_info, // Assign the CURL socket to the ASIO socket // tcp::socket::assign works with native socket handles on both platforms boost::system::error_code ec; - socket_info->handle->assign(boost::asio::ip::tcp::v4(), socket_info->sockfd, ec); + socket_info->handle->assign(boost::asio::ip::tcp::v4(), + socket_info->sockfd, ec); if (ec) { - std::cerr << "Failed to assign socket: " << ec.message() << std::endl; + std::cerr << "Failed to assign socket: " << ec.message() << + std::endl; socket_info->handle.reset(); return; } @@ -249,36 +255,41 @@ void CurlMultiManager::start_socket_monitor(SocketInfo* socket_info, // Create and store handler in SocketInfo to keep it alive // Use weak_ptr in capture to avoid circular reference - socket_info->read_handler = std::make_shared>(); - std::weak_ptr> weak_read_handler = socket_info->read_handler; - *socket_info->read_handler = [weak_self, sockfd, weak_handle, weak_read_handler]() { - // Check if manager and handle are still valid - const auto self = weak_self.lock(); - const auto handle = weak_handle.lock(); - if (!self || !handle) { - return; - } + socket_info->read_handler = std::make_shared>(); + std::weak_ptr> weak_read_handler = socket_info + ->read_handler; + *socket_info->read_handler = [weak_self, sockfd, weak_handle, + weak_read_handler]() { + // Check if manager and handle are still valid + const auto self = weak_self.lock(); + const auto handle = weak_handle.lock(); + if (!self || !handle) { + return; + } - handle->async_wait( - boost::asio::ip::tcp::socket::wait_read, - [weak_self, sockfd, weak_handle, weak_read_handler]( + handle->async_wait( + boost::asio::ip::tcp::socket::wait_read, + [weak_self, sockfd, weak_handle, weak_read_handler]( const boost::system::error_code& ec) { - // If operation was canceled or had an error, don't re-register - if (ec) { - return; - } - - if (const auto self = weak_self.lock()) { - self->handle_socket_action(sockfd, CURL_CSELECT_IN); - - // Always try to re-register for continuous monitoring - // The validity check at the top of read_handler will stop it if needed - if (const auto handler = weak_read_handler.lock()) { - (*handler)(); // Recursive call + // If operation was canceled or had an error, don't re-register + if (ec) { + return; + } + + if (const auto self = weak_self.lock()) { + self->handle_socket_action( + sockfd, CURL_CSELECT_IN); + + // Always try to re-register for continuous monitoring + // The validity check at the top of read_handler will stop it if needed + if (const auto handler = weak_read_handler. + lock()) { + (*handler)(); // Recursive call + } } - } - }); - }; + }); + }; (*socket_info->read_handler)(); // Initial call } } @@ -292,36 +303,41 @@ void CurlMultiManager::start_socket_monitor(SocketInfo* socket_info, // Create and store handler in SocketInfo to keep it alive // Use weak_ptr in capture to avoid circular reference - socket_info->write_handler = std::make_shared>(); - std::weak_ptr> weak_write_handler = socket_info->write_handler; - *socket_info->write_handler = [weak_self, sockfd, weak_handle, weak_write_handler]() { - // Check if manager and handle are still valid - const auto self = weak_self.lock(); - const auto handle = weak_handle.lock(); - if (!self || !handle) { - return; - } + socket_info->write_handler = std::make_shared>(); + std::weak_ptr> weak_write_handler = + socket_info->write_handler; + *socket_info->write_handler = [weak_self, sockfd, weak_handle, + weak_write_handler]() { + // Check if manager and handle are still valid + const auto self = weak_self.lock(); + const auto handle = weak_handle.lock(); + if (!self || !handle) { + return; + } - handle->async_wait( - boost::asio::ip::tcp::socket::wait_write, - [weak_self, sockfd, weak_handle, weak_write_handler]( + handle->async_wait( + boost::asio::ip::tcp::socket::wait_write, + [weak_self, sockfd, weak_handle, weak_write_handler]( const boost::system::error_code& ec) { - // If operation was canceled or had an error, don't re-register - if (ec) { - return; - } - - if (const auto self = weak_self.lock()) { - self->handle_socket_action(sockfd, CURL_CSELECT_OUT); - - // Always try to re-register for continuous monitoring - // The validity check at the top of write_handler will stop it if needed - if (const auto handler = weak_write_handler.lock()) { - (*handler)(); // Recursive call + // If operation was canceled or had an error, don't re-register + if (ec) { + return; + } + + if (const auto self = weak_self.lock()) { + self->handle_socket_action( + sockfd, CURL_CSELECT_OUT); + + // Always try to re-register for continuous monitoring + // The validity check at the top of write_handler will stop it if needed + if (const auto handler = weak_write_handler. + lock()) { + (*handler)(); // Recursive call + } } - } - }); - }; + }); + }; (*socket_info->write_handler)(); // Initial call } } diff --git a/libs/server-sent-events/src/curl_client.cpp b/libs/server-sent-events/src/curl_client.cpp index 51d42d3ff..16df46509 100644 --- a/libs/server-sent-events/src/curl_client.cpp +++ b/libs/server-sent-events/src/curl_client.cpp @@ -416,7 +416,7 @@ void CurlClient::PerformRequestWithMulti( // Initialize parser for new connection (last_event_id is tracked separately) context->init_parser(); - CURL* curl = curl_easy_init(); + std::shared_ptrcurl (curl_easy_init(), curl_easy_cleanup); if (!curl) { if (context->is_shutting_down()) { return; @@ -427,9 +427,8 @@ void CurlClient::PerformRequestWithMulti( } curl_slist* headers = nullptr; - if (!SetupCurlOptions(curl, &headers, *context)) { + if (!SetupCurlOptions(curl.get(), &headers, *context)) { // setup_curl_options returned false, indicating an error (it already logged the error) - curl_easy_cleanup(curl); if (context->is_shutting_down()) { return; @@ -445,7 +444,6 @@ void CurlClient::PerformRequestWithMulti( multi_manager->add_handle(curl, headers, [weak_context](CURL* easy, CURLcode res) { auto context = weak_context.lock(); if (!context) { - curl_easy_cleanup(easy); return; } @@ -457,7 +455,6 @@ void CurlClient::PerformRequestWithMulti( auto status = static_cast(response_code); auto status_class = http::to_status_class(status); - curl_easy_cleanup(easy); if (context->is_shutting_down()) { return; diff --git a/libs/server-sent-events/tests/curl_client_test.cpp b/libs/server-sent-events/tests/curl_client_test.cpp index 425258b7c..d110ed4ea 100644 --- a/libs/server-sent-events/tests/curl_client_test.cpp +++ b/libs/server-sent-events/tests/curl_client_test.cpp @@ -829,7 +829,6 @@ TEST(CurlClientTest, HandlesRapidEvents) { // Shutdown-specific tests - critical for preventing crashes/hangs in user applications TEST(CurlClientTest, ShutdownDuringBackoffDelay) { - // Tests curl_client.cpp:138 - on_backoff checks shutting_down_ // This ensures clean shutdown during backoff/retry wait period std::atomic connection_attempts{0}; @@ -874,7 +873,6 @@ TEST(CurlClientTest, ShutdownDuringBackoffDelay) { } TEST(CurlClientTest, ShutdownDuringDataReception) { - // Tests curl_client.cpp:235 - WriteCallback checks shutting_down_ // This covers the branch where we abort during SSE data parsing SimpleLatch server_sending(1); SimpleLatch client_received_some(1); @@ -901,12 +899,13 @@ TEST(CurlClientTest, ShutdownDuringDataReception) { auto port = server.start(handler); IoContextRunner runner; - EventCollector collector; + // Shared ptr to prevent handling events during destruction. + std::shared_ptr collector = std::make_shared(); auto client = Builder(runner.context().get_executor(), "http://localhost:" + std::to_string(port)) - .receiver([&](Event e) { - collector.add_event(std::move(e)); - if (collector.events().size() >= 2) { + .receiver([collector, &client_received_some](Event e) { + collector->add_event(std::move(e)); + if (collector->events().size() >= 2) { client_received_some.count_down(); } }) @@ -930,7 +929,6 @@ TEST(CurlClientTest, ShutdownDuringDataReception) { } TEST(CurlClientTest, ShutdownDuringProgressCallback) { - // Tests curl_client.cpp:188 - ProgressCallback checks shutting_down_ // This ensures we can abort during slow data transfer SimpleLatch server_started(1); @@ -1040,7 +1038,6 @@ TEST(CurlClientTest, ShutdownAfterConnectionClosed) { } TEST(CurlClientTest, ShutdownDuringConnectionAttempt) { - // Tests curl_client.cpp:439 - perform_request checks shutting_down_ at start // Server that delays before responding to test shutdown during connection phase SimpleLatch connection_started(1); From e708abd0aa9826bfea20c66b68a065cd3573a572 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 17 Oct 2025 15:44:04 -0700 Subject: [PATCH 72/90] CMake build updates. --- libs/internal/src/CMakeLists.txt | 3 +-- .../include/launchdarkly/network/curl_multi_manager.hpp | 2 +- libs/networking/src/CMakeLists.txt | 3 ++- libs/networking/src/curl_multi_manager.cpp | 4 +--- libs/server-sent-events/src/CMakeLists.txt | 3 +-- 5 files changed, 6 insertions(+), 9 deletions(-) diff --git a/libs/internal/src/CMakeLists.txt b/libs/internal/src/CMakeLists.txt index b249b03f5..3a1b9e171 100644 --- a/libs/internal/src/CMakeLists.txt +++ b/libs/internal/src/CMakeLists.txt @@ -49,7 +49,6 @@ set(INTERNAL_SOURCES if (LD_CURL_NETWORKING) message(STATUS "LaunchDarkly Internal: CURL networking enabled") - find_package(CURL REQUIRED) list(APPEND INTERNAL_SOURCES network/curl_requester.cpp) endif() @@ -70,7 +69,7 @@ target_link_libraries(${LIBNAME} PRIVATE Boost::url Boost::json OpenSSL::SSL Boost::disable_autolinking Boost::headers tl::expected foxy) if (LD_CURL_NETWORKING) - target_link_libraries(${LIBNAME} PRIVATE CURL::libcurl launchdarkly::networking) + target_link_libraries(${LIBNAME} PRIVATE launchdarkly::networking) target_compile_definitions(${LIBNAME} PRIVATE LD_CURL_NETWORKING) endif() diff --git a/libs/networking/include/launchdarkly/network/curl_multi_manager.hpp b/libs/networking/include/launchdarkly/network/curl_multi_manager.hpp index d7fd86118..8508eee39 100644 --- a/libs/networking/include/launchdarkly/network/curl_multi_manager.hpp +++ b/libs/networking/include/launchdarkly/network/curl_multi_manager.hpp @@ -60,7 +60,7 @@ class CurlMultiManager : public std::enable_shared_from_this { * @param headers The curl_slist headers (will be freed automatically) * @param callback Called when the transfer completes */ - void add_handle(std::shared_ptr easy, + void add_handle(const std::shared_ptr& easy, curl_slist* headers, CompletionCallback callback); diff --git a/libs/networking/src/CMakeLists.txt b/libs/networking/src/CMakeLists.txt index 975f8e9c0..803c8df75 100644 --- a/libs/networking/src/CMakeLists.txt +++ b/libs/networking/src/CMakeLists.txt @@ -15,9 +15,10 @@ add_library(launchdarkly::networking ALIAS ${LIBNAME}) message(STATUS "LaunchDarklyNetworking_SOURCE_DIR=${LaunchDarklyNetworking_SOURCE_DIR}") target_link_libraries(${LIBNAME} + PUBLIC + CURL::libcurl PRIVATE Boost::headers - CURL::libcurl Boost::disable_autolinking ) diff --git a/libs/networking/src/curl_multi_manager.cpp b/libs/networking/src/curl_multi_manager.cpp index 0be92ef5f..667481745 100644 --- a/libs/networking/src/curl_multi_manager.cpp +++ b/libs/networking/src/curl_multi_manager.cpp @@ -42,13 +42,11 @@ CurlMultiManager::~CurlMultiManager() { header_it != headers_.end() && header_it->second) { curl_slist_free_all(header_it->second); } - - // curl_easy_cleanup(easy); } } } -void CurlMultiManager::add_handle(std::shared_ptr easy, +void CurlMultiManager::add_handle(const std::shared_ptr& easy, curl_slist* headers, CompletionCallback callback) { if (const CURLMcode rc = curl_multi_add_handle( diff --git a/libs/server-sent-events/src/CMakeLists.txt b/libs/server-sent-events/src/CMakeLists.txt index c3a4d36dd..128b7a816 100644 --- a/libs/server-sent-events/src/CMakeLists.txt +++ b/libs/server-sent-events/src/CMakeLists.txt @@ -16,7 +16,6 @@ set(SSE_SOURCES if (LD_CURL_NETWORKING) message(STATUS "LaunchDarkly SSE: CURL networking enabled") - find_package(CURL REQUIRED) list(APPEND SSE_SOURCES curl_client.cpp) endif() @@ -28,7 +27,7 @@ target_link_libraries(${LIBNAME} ) if (LD_CURL_NETWORKING) - target_link_libraries(${LIBNAME} PRIVATE CURL::libcurl launchdarkly::networking) + target_link_libraries(${LIBNAME} PRIVATE launchdarkly::networking) target_compile_definitions(${LIBNAME} PRIVATE LD_CURL_NETWORKING) endif() From ca6f43e7e8a2458200c75a816c440a950bf9a390 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 17 Oct 2025 16:13:53 -0700 Subject: [PATCH 73/90] Public link boost headers. --- libs/networking/src/CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/networking/src/CMakeLists.txt b/libs/networking/src/CMakeLists.txt index 803c8df75..27be5c1e7 100644 --- a/libs/networking/src/CMakeLists.txt +++ b/libs/networking/src/CMakeLists.txt @@ -17,8 +17,8 @@ message(STATUS "LaunchDarklyNetworking_SOURCE_DIR=${LaunchDarklyNetworking_SOURC target_link_libraries(${LIBNAME} PUBLIC CURL::libcurl - PRIVATE Boost::headers + PRIVATE Boost::disable_autolinking ) From d3b18cd9937965416b3c05ad3edc1cb480b32ad4 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 17 Oct 2025 16:35:48 -0700 Subject: [PATCH 74/90] Refine curl handle lifetime. --- libs/internal/src/network/curl_requester.cpp | 4 +-- .../network/curl_multi_manager.hpp | 8 +----- libs/networking/src/curl_multi_manager.cpp | 28 ++++++------------- libs/server-sent-events/src/curl_client.cpp | 4 +-- 4 files changed, 13 insertions(+), 31 deletions(-) diff --git a/libs/internal/src/network/curl_requester.cpp b/libs/internal/src/network/curl_requester.cpp index 70eaddf65..1c3a61884 100644 --- a/libs/internal/src/network/curl_requester.cpp +++ b/libs/internal/src/network/curl_requester.cpp @@ -225,7 +225,7 @@ void CurlRequester::PerformRequestWithMulti(std::shared_ptr mu // Add handle to multi manager for async processing // Headers will be freed automatically by CurlMultiManager - multi_manager->add_handle(curl, headers, [ctx](CURL* easy, CURLcode result) { + multi_manager->add_handle(curl, headers, [ctx](std::shared_ptr easy, CURLcode result) { // This callback runs on the executor when the request completes // Check for errors @@ -238,7 +238,7 @@ void CurlRequester::PerformRequestWithMulti(std::shared_ptr mu // Get HTTP response code long response_code = 0; - curl_easy_getinfo(easy, CURLINFO_RESPONSE_CODE, &response_code); + curl_easy_getinfo(easy.get(), CURLINFO_RESPONSE_CODE, &response_code); // Invoke the user's callback with the result ctx->callback(HttpResult( diff --git a/libs/networking/include/launchdarkly/network/curl_multi_manager.hpp b/libs/networking/include/launchdarkly/network/curl_multi_manager.hpp index 8508eee39..d4d6a06ed 100644 --- a/libs/networking/include/launchdarkly/network/curl_multi_manager.hpp +++ b/libs/networking/include/launchdarkly/network/curl_multi_manager.hpp @@ -37,7 +37,7 @@ class CurlMultiManager : public std::enable_shared_from_this { * Callback invoked when an easy handle completes (success or error). * Parameters: CURL* easy handle, CURLcode result */ - using CompletionCallback = std::function; + using CompletionCallback = std::function, CURLcode)>; /** * Create a CurlMultiManager on the given executor. @@ -64,12 +64,6 @@ class CurlMultiManager : public std::enable_shared_from_this { curl_slist* headers, CompletionCallback callback); - /** - * Remove an easy handle from management. - * @param easy The CURL easy handle to remove - */ - void remove_handle(CURL* easy); - private: explicit CurlMultiManager(boost::asio::any_io_executor executor); diff --git a/libs/networking/src/curl_multi_manager.cpp b/libs/networking/src/curl_multi_manager.cpp index 667481745..4d58248ed 100644 --- a/libs/networking/src/curl_multi_manager.cpp +++ b/libs/networking/src/curl_multi_manager.cpp @@ -70,22 +70,6 @@ void CurlMultiManager::add_handle(const std::shared_ptr& easy, } } -void CurlMultiManager::remove_handle(CURL* easy) { - curl_multi_remove_handle(multi_handle_.get(), easy); - - std::lock_guard lock(mutex_); - - // Free headers if they exist - if (const auto header_it = headers_.find(easy); - header_it != headers_.end() && header_it->second) { - curl_slist_free_all(header_it->second); - } - - callbacks_.erase(easy); - headers_.erase(easy); - handles_.erase(easy); -} - int CurlMultiManager::socket_callback(CURL* easy, curl_socket_t s, int what, @@ -193,8 +177,6 @@ void CurlMultiManager::check_multi_info() { headers = header_it->second; headers_.erase(header_it); } - - handles_.erase(easy); } // Remove from multi handle @@ -205,11 +187,17 @@ void CurlMultiManager::check_multi_info() { curl_slist_free_all(headers); } + // We don't need to keep the curl handle alive any longer, but we + // do want the handle to remain active for the duration of the + // callback. + auto handle = handles_[easy]; + handles_.erase(easy); + // Invoke completion callback if (callback) { boost::asio::post(executor_, [callback = std::move(callback), - easy, result]() { - callback(easy, result); + easy, result, handle]() { + callback(handle, result); }); } } diff --git a/libs/server-sent-events/src/curl_client.cpp b/libs/server-sent-events/src/curl_client.cpp index 16df46509..4f416e5f2 100644 --- a/libs/server-sent-events/src/curl_client.cpp +++ b/libs/server-sent-events/src/curl_client.cpp @@ -441,7 +441,7 @@ void CurlClient::PerformRequestWithMulti( // Add handle to multi manager for async processing // Headers will be freed automatically by CurlMultiManager std::weak_ptr weak_context = context; - multi_manager->add_handle(curl, headers, [weak_context](CURL* easy, CURLcode res) { + multi_manager->add_handle(curl, headers, [weak_context](std::shared_ptr easy, CURLcode res) { auto context = weak_context.lock(); if (!context) { return; @@ -449,7 +449,7 @@ void CurlClient::PerformRequestWithMulti( // Get response code long response_code = 0; - curl_easy_getinfo(easy, CURLINFO_RESPONSE_CODE, &response_code); + curl_easy_getinfo(easy.get(), CURLINFO_RESPONSE_CODE, &response_code); // Handle HTTP status codes auto status = static_cast(response_code); From 0c2cbfe3d88dc5051f1cf153190fe01fed1d86ae Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 17 Oct 2025 17:01:04 -0700 Subject: [PATCH 75/90] Add debug logging --- libs/networking/src/curl_multi_manager.cpp | 2 +- libs/server-sent-events/src/curl_client.cpp | 1 + libs/server-sent-events/tests/curl_client_test.cpp | 14 ++++++++++---- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/libs/networking/src/curl_multi_manager.cpp b/libs/networking/src/curl_multi_manager.cpp index 4d58248ed..0986258c9 100644 --- a/libs/networking/src/curl_multi_manager.cpp +++ b/libs/networking/src/curl_multi_manager.cpp @@ -196,7 +196,7 @@ void CurlMultiManager::check_multi_info() { // Invoke completion callback if (callback) { boost::asio::post(executor_, [callback = std::move(callback), - easy, result, handle]() { + result, handle]() { callback(handle, result); }); } diff --git a/libs/server-sent-events/src/curl_client.cpp b/libs/server-sent-events/src/curl_client.cpp index 4f416e5f2..48f8ba589 100644 --- a/libs/server-sent-events/src/curl_client.cpp +++ b/libs/server-sent-events/src/curl_client.cpp @@ -326,6 +326,7 @@ int CurlClient::ProgressCallback(void* clientp, now - context->last_progress_time); if (elapsed > *context->read_timeout) { + context->log_message("Read timeout, aborting curl transfer."); return kCurlTransferAbort; } } diff --git a/libs/server-sent-events/tests/curl_client_test.cpp b/libs/server-sent-events/tests/curl_client_test.cpp index d110ed4ea..08a3d176c 100644 --- a/libs/server-sent-events/tests/curl_client_test.cpp +++ b/libs/server-sent-events/tests/curl_client_test.cpp @@ -640,7 +640,13 @@ TEST(CurlClientTest, RespectsReadTimeout) { auto client = Builder(runner.context().get_executor(), "http://localhost:" + std::to_string(port)) .receiver([&](Event e) { collector.add_event(std::move(e)); }) - .errors([&](Error e) { collector.add_error(std::move(e)); }) + .errors([&](Error e) { + std::cerr << "Error" << e.index() << std::endl; + collector.add_error(std::move(e)); + }) + .logger([&](const std::string& message) { + std::cerr << "log_message" << message << std::endl; + }) .read_timeout(500ms) // Short timeout for test .initial_reconnect_delay(50ms) .build(); @@ -648,14 +654,14 @@ TEST(CurlClientTest, RespectsReadTimeout) { client->async_connect(); // Should receive the first event - ASSERT_TRUE(collector.wait_for_events(1, 2000ms)); + ASSERT_TRUE(collector.wait_for_events(1, 100ms)); // Then should get a timeout error - ASSERT_TRUE(collector.wait_for_errors(1, 3000ms)); + ASSERT_TRUE(collector.wait_for_errors(1, 1000ms)); SimpleLatch shutdown_latch(1); client->async_shutdown([&] { shutdown_latch.count_down(); }); - EXPECT_TRUE(shutdown_latch.wait_for(5000ms)); + EXPECT_TRUE(shutdown_latch.wait_for(100ms)); } // Resource management tests From 583341e605aeaf4234c9fb5c8efddab38043fd41 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 17 Oct 2025 17:42:46 -0700 Subject: [PATCH 76/90] Remove debug log. --- libs/server-sent-events/src/curl_client.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/libs/server-sent-events/src/curl_client.cpp b/libs/server-sent-events/src/curl_client.cpp index 48f8ba589..4f416e5f2 100644 --- a/libs/server-sent-events/src/curl_client.cpp +++ b/libs/server-sent-events/src/curl_client.cpp @@ -326,7 +326,6 @@ int CurlClient::ProgressCallback(void* clientp, now - context->last_progress_time); if (elapsed > *context->read_timeout) { - context->log_message("Read timeout, aborting curl transfer."); return kCurlTransferAbort; } } From fd469e0408186f10a2db45c6b65d74a6459734b5 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 17 Oct 2025 18:04:13 -0700 Subject: [PATCH 77/90] Alternate timeout method. --- libs/internal/src/network/curl_requester.cpp | 12 +- .../network/curl_multi_manager.hpp | 43 ++++++- libs/networking/src/curl_multi_manager.cpp | 112 +++++++++++++++++- libs/server-sent-events/src/curl_client.cpp | 16 ++- 4 files changed, 172 insertions(+), 11 deletions(-) diff --git a/libs/internal/src/network/curl_requester.cpp b/libs/internal/src/network/curl_requester.cpp index 1c3a61884..ae1c24d4f 100644 --- a/libs/internal/src/network/curl_requester.cpp +++ b/libs/internal/src/network/curl_requester.cpp @@ -225,13 +225,19 @@ void CurlRequester::PerformRequestWithMulti(std::shared_ptr mu // Add handle to multi manager for async processing // Headers will be freed automatically by CurlMultiManager - multi_manager->add_handle(curl, headers, [ctx](std::shared_ptr easy, CURLcode result) { + multi_manager->add_handle(curl, headers, [ctx](std::shared_ptr easy, CurlMultiManager::Result result) { // This callback runs on the executor when the request completes + // Handle read timeout (shouldn't happen for regular requests, but handle it anyway) + if (result.type == CurlMultiManager::Result::Type::ReadTimeout) { + ctx->callback(HttpResult("Request timed out")); + return; + } + // Check for errors - if (result != CURLE_OK) { + if (result.curl_code != CURLE_OK) { std::string error_message = kErrorCurlPrefix; - error_message += curl_easy_strerror(result); + error_message += curl_easy_strerror(result.curl_code); ctx->callback(HttpResult(error_message)); return; } diff --git a/libs/networking/include/launchdarkly/network/curl_multi_manager.hpp b/libs/networking/include/launchdarkly/network/curl_multi_manager.hpp index d4d6a06ed..ed2dc9b70 100644 --- a/libs/networking/include/launchdarkly/network/curl_multi_manager.hpp +++ b/libs/networking/include/launchdarkly/network/curl_multi_manager.hpp @@ -11,6 +11,7 @@ #include #include #include +#include namespace launchdarkly::network { @@ -33,11 +34,32 @@ using SocketHandle = boost::asio::ip::tcp::socket; */ class CurlMultiManager : public std::enable_shared_from_this { public: + /** + * Result of a CURL operation - either CURLcode or read timeout. + */ + struct Result { + enum class Type { + CurlCode, + ReadTimeout + }; + + Type type; + CURLcode curl_code; // Only valid if type == CurlCode + + static Result FromCurlCode(CURLcode code) { + return Result{Type::CurlCode, code}; + } + + static Result FromReadTimeout() { + return Result{Type::ReadTimeout, CURLE_OK}; + } + }; + /** * Callback invoked when an easy handle completes (success or error). - * Parameters: CURL* easy handle, CURLcode result + * Parameters: CURL* easy handle, Result */ - using CompletionCallback = std::function, CURLcode)>; + using CompletionCallback = std::function, Result)>; /** * Create a CurlMultiManager on the given executor. @@ -59,10 +81,12 @@ class CurlMultiManager : public std::enable_shared_from_this { * @param easy The CURL easy handle (must be configured) * @param headers The curl_slist headers (will be freed automatically) * @param callback Called when the transfer completes + * @param read_timeout Optional read timeout duration */ void add_handle(const std::shared_ptr& easy, curl_slist* headers, - CompletionCallback callback); + CompletionCallback callback, + std::optional read_timeout = std::nullopt); private: explicit CurlMultiManager(boost::asio::any_io_executor executor); @@ -86,6 +110,9 @@ class CurlMultiManager : public std::enable_shared_from_this { // Check for completed transfers void check_multi_info(); + // Handle read timeout for a specific handle + void handle_read_timeout(CURL* easy); + // Per-socket data struct SocketInfo { curl_socket_t sockfd; @@ -98,6 +125,15 @@ class CurlMultiManager : public std::enable_shared_from_this { void start_socket_monitor(SocketInfo* socket_info, int action); + // Reset read timeout timer for a handle + void reset_read_timeout(CURL* easy); + + // Per-handle read timeout data + struct HandleTimeoutInfo { + std::optional timeout_duration; + std::shared_ptr timer; + }; + boost::asio::any_io_executor executor_; // CURLM* multi_handle_; std::unique_ptr multi_handle_; @@ -107,6 +143,7 @@ class CurlMultiManager : public std::enable_shared_from_this { std::map callbacks_; std::map headers_; std::map> handles_; + std::map handle_timeouts_; std::map sockets_; // Managed socket info int still_running_{0}; }; diff --git a/libs/networking/src/curl_multi_manager.cpp b/libs/networking/src/curl_multi_manager.cpp index 0986258c9..9c9157a80 100644 --- a/libs/networking/src/curl_multi_manager.cpp +++ b/libs/networking/src/curl_multi_manager.cpp @@ -48,7 +48,8 @@ CurlMultiManager::~CurlMultiManager() { void CurlMultiManager::add_handle(const std::shared_ptr& easy, curl_slist* headers, - CompletionCallback callback) { + CompletionCallback callback, + std::optional read_timeout) { if (const CURLMcode rc = curl_multi_add_handle( multi_handle_.get(), easy.get()); rc != CURLM_OK) { @@ -67,6 +68,24 @@ void CurlMultiManager::add_handle(const std::shared_ptr& easy, callbacks_[easy.get()] = std::move(callback); headers_[easy.get()] = headers; handles_[easy.get()] = easy; + + // Setup read timeout timer if specified + if (read_timeout) { + auto timer = std::make_shared(executor_); + handle_timeouts_[easy.get()] = HandleTimeoutInfo{read_timeout, timer}; + + // Start the timeout timer + timer->expires_after(*read_timeout); + auto weak_self = weak_from_this(); + CURL* easy_ptr = easy.get(); + timer->async_wait([weak_self, easy_ptr](const boost::system::error_code& ec) { + if (!ec) { + if (auto self = weak_self.lock()) { + self->handle_read_timeout(easy_ptr); + } + } + }); + } } } @@ -79,6 +98,9 @@ int CurlMultiManager::socket_callback(CURL* easy, std::lock_guard lock(manager->mutex_); + // Reset read timeout on any socket activity + manager->reset_read_timeout(easy); + if (what == CURL_POLL_REMOVE) { // Remove socket from managed container if (const auto it = manager->sockets_.find(s); @@ -177,6 +199,13 @@ void CurlMultiManager::check_multi_info() { headers = header_it->second; headers_.erase(header_it); } + + // Cancel and remove timeout timer + if (auto timeout_it = handle_timeouts_.find(easy); + timeout_it != handle_timeouts_.end()) { + timeout_it->second.timer->cancel(); + handle_timeouts_.erase(timeout_it); + } } // Remove from multi handle @@ -197,7 +226,7 @@ void CurlMultiManager::check_multi_info() { if (callback) { boost::asio::post(executor_, [callback = std::move(callback), result, handle]() { - callback(handle, result); + callback(handle, Result::FromCurlCode(result)); }); } } @@ -243,7 +272,7 @@ void CurlMultiManager::start_socket_monitor(SocketInfo* socket_info, // Use weak_ptr in capture to avoid circular reference socket_info->read_handler = std::make_shared>(); - std::weak_ptr> weak_read_handler = socket_info + std::weak_ptr weak_read_handler = socket_info ->read_handler; *socket_info->read_handler = [weak_self, sockfd, weak_handle, weak_read_handler]() { @@ -328,6 +357,83 @@ void CurlMultiManager::start_socket_monitor(SocketInfo* socket_info, } } } + +void CurlMultiManager::reset_read_timeout(CURL* easy) { + // Must be called with mutex_ locked + auto timeout_it = handle_timeouts_.find(easy); + if (timeout_it != handle_timeouts_.end() && timeout_it->second.timer) { + auto& timeout_info = timeout_it->second; + timeout_info.timer->cancel(); + timeout_info.timer->expires_after(*timeout_info.timeout_duration); + + auto weak_self = weak_from_this(); + CURL* easy_ptr = easy; + timeout_info.timer->async_wait([weak_self, easy_ptr](const boost::system::error_code& ec) { + if (!ec) { + if (auto self = weak_self.lock()) { + self->handle_read_timeout(easy_ptr); + } + } + }); + } +} + +void CurlMultiManager::handle_read_timeout(CURL* easy) { + CompletionCallback callback; + curl_slist* headers = nullptr; + std::shared_ptr handle; + + { + std::lock_guard lock(mutex_); + + // Check if handle still exists + auto it = callbacks_.find(easy); + if (it == callbacks_.end()) { + return; // Handle already completed + } + + // Get and remove callback + callback = std::move(it->second); + callbacks_.erase(it); + + // Get and remove headers + if (auto header_it = headers_.find(easy); + header_it != headers_.end()) { + headers = header_it->second; + headers_.erase(header_it); + } + + // Get and remove handle + if (auto handle_it = handles_.find(easy); + handle_it != handles_.end()) { + handle = handle_it->second; + handles_.erase(handle_it); + } + + // Remove timeout info + if (auto timeout_it = handle_timeouts_.find(easy); + timeout_it != handle_timeouts_.end()) { + timeout_it->second.timer->cancel(); + handle_timeouts_.erase(timeout_it); + } + } + + // Remove from multi handle + curl_multi_remove_handle(multi_handle_.get(), easy); + + // Free headers + if (headers) { + curl_slist_free_all(headers); + } + + // Invoke completion callback with read timeout result + if (callback) { + boost::asio::post(executor_, [callback = std::move(callback), + handle]() { + callback(handle, Result::FromReadTimeout()); + }); + } +} } // namespace launchdarkly::network #endif // LD_CURL_NETWORKING \ No newline at end of file diff --git a/libs/server-sent-events/src/curl_client.cpp b/libs/server-sent-events/src/curl_client.cpp index 4f416e5f2..0d8dde39e 100644 --- a/libs/server-sent-events/src/curl_client.cpp +++ b/libs/server-sent-events/src/curl_client.cpp @@ -441,12 +441,24 @@ void CurlClient::PerformRequestWithMulti( // Add handle to multi manager for async processing // Headers will be freed automatically by CurlMultiManager std::weak_ptr weak_context = context; - multi_manager->add_handle(curl, headers, [weak_context](std::shared_ptr easy, CURLcode res) { + multi_manager->add_handle(curl, headers, [weak_context](std::shared_ptr easy, CurlMultiManager::Result result) { auto context = weak_context.lock(); if (!context) { return; } + // Check if this was a read timeout from the multi manager + if (result.type == CurlMultiManager::Result::Type::ReadTimeout) { + if (!context->is_shutting_down()) { + context->error(errors::ReadTimeout{context->read_timeout}); + context->backoff("read timeout - no data received"); + } + return; + } + + // Handle CURLcode result + CURLcode res = result.curl_code; + // Get response code long response_code = 0; curl_easy_getinfo(easy.get(), CURLINFO_RESPONSE_CODE, &response_code); @@ -524,7 +536,7 @@ void CurlClient::PerformRequestWithMulti( ss << "HTTP status " << static_cast(status); context->backoff(ss.str()); } - }); + }, context->read_timeout); } void CurlClient::async_shutdown(std::function completion) { From e3965f9904d6ec0587e5966b5671dd3b663057a8 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Sat, 18 Oct 2025 09:46:57 -0700 Subject: [PATCH 78/90] Don't link boost to networking library --- libs/networking/src/CMakeLists.txt | 4 ---- 1 file changed, 4 deletions(-) diff --git a/libs/networking/src/CMakeLists.txt b/libs/networking/src/CMakeLists.txt index 27be5c1e7..317a7f18b 100644 --- a/libs/networking/src/CMakeLists.txt +++ b/libs/networking/src/CMakeLists.txt @@ -17,9 +17,6 @@ message(STATUS "LaunchDarklyNetworking_SOURCE_DIR=${LaunchDarklyNetworking_SOURC target_link_libraries(${LIBNAME} PUBLIC CURL::libcurl - Boost::headers - PRIVATE - Boost::disable_autolinking ) # Need the public headers to build. @@ -35,7 +32,6 @@ target_compile_features(${LIBNAME} PUBLIC cxx_std_17) target_compile_definitions(${LIBNAME} PUBLIC LD_CURL_NETWORKING - BOOST_ASIO_HEADER_ONLY ) # Using PUBLIC_HEADERS would flatten the include. From 8fdc421524ab95e2f2b11d3eed0fb0979d6aa47b Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Sat, 18 Oct 2025 09:48:20 -0700 Subject: [PATCH 79/90] Iterate --- libs/networking/src/CMakeLists.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/libs/networking/src/CMakeLists.txt b/libs/networking/src/CMakeLists.txt index 317a7f18b..64ea4b529 100644 --- a/libs/networking/src/CMakeLists.txt +++ b/libs/networking/src/CMakeLists.txt @@ -17,6 +17,7 @@ message(STATUS "LaunchDarklyNetworking_SOURCE_DIR=${LaunchDarklyNetworking_SOURC target_link_libraries(${LIBNAME} PUBLIC CURL::libcurl + Boost::headers ) # Need the public headers to build. From 23ebda32f044ec092dddcdc23cd6d41f3e73f2e4 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Sat, 18 Oct 2025 09:55:37 -0700 Subject: [PATCH 80/90] Enable asio separate compilation. --- libs/networking/src/CMakeLists.txt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/libs/networking/src/CMakeLists.txt b/libs/networking/src/CMakeLists.txt index 64ea4b529..bf229726d 100644 --- a/libs/networking/src/CMakeLists.txt +++ b/libs/networking/src/CMakeLists.txt @@ -18,6 +18,8 @@ target_link_libraries(${LIBNAME} PUBLIC CURL::libcurl Boost::headers + PRIVATE + Boost::disable_autolinking ) # Need the public headers to build. @@ -33,6 +35,8 @@ target_compile_features(${LIBNAME} PUBLIC cxx_std_17) target_compile_definitions(${LIBNAME} PUBLIC LD_CURL_NETWORKING + BOOST_ASIO_SEPARATE_COMPILATION=1 + BOOST_BEAST_SEPARATE_COMPILATION=1 ) # Using PUBLIC_HEADERS would flatten the include. From 7502d88e65ab871212df590f0c675977eb51a668 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Sat, 18 Oct 2025 10:11:07 -0700 Subject: [PATCH 81/90] Latest redis-plus-plus. --- cmake/redis-plus-plus.cmake | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmake/redis-plus-plus.cmake b/cmake/redis-plus-plus.cmake index 79bd2a27d..c45a72c85 100644 --- a/cmake/redis-plus-plus.cmake +++ b/cmake/redis-plus-plus.cmake @@ -24,7 +24,7 @@ set(REDIS_PLUS_PLUS_BUILD_TEST OFF CACHE BOOL "" FORCE) FetchContent_Declare(redis-plus-plus GIT_REPOSITORY https://github.com/sewenew/redis-plus-plus.git # Post 1.3.15. Required to support FetchContent post 1.3.7 where it was broken. - GIT_TAG 84f37e95d9112193fd433f65402d3d183f0b9cf7 + GIT_TAG fc67c2ebf929ae2cf3b31d959767233f39c5df6a GIT_SHALLOW TRUE ) From 417b1dea70819184fefe85689a2746761b821b3d Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Sat, 18 Oct 2025 13:57:20 -0700 Subject: [PATCH 82/90] Comments and fix bad cast. --- libs/server-sent-events/src/curl_client.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/server-sent-events/src/curl_client.cpp b/libs/server-sent-events/src/curl_client.cpp index 0d8dde39e..c410db004 100644 --- a/libs/server-sent-events/src/curl_client.cpp +++ b/libs/server-sent-events/src/curl_client.cpp @@ -391,14 +391,14 @@ size_t CurlClient::HeaderCallback(const char* buffer, size_t nitems, void* userdata) { const size_t total_size = size * nitems; - auto* client = static_cast(userdata); + auto* context = static_cast(userdata); // Check for Content-Type header if (const std::string header(buffer, total_size); header.find("Content-Type:") == 0 || header.find("content-type:") == 0) { if (header.find("text/event-stream") == std::string::npos) { - client->log_message("warning: unexpected Content-Type: " + header); + context->log_message("warning: unexpected Content-Type: " + header); } } From 3782ee99c1a5b7b3dcac960575590f127d4d08c9 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Sat, 18 Oct 2025 14:23:49 -0700 Subject: [PATCH 83/90] Remove debug logging. --- libs/networking/src/curl_multi_manager.cpp | 63 ++++++++++----------- libs/server-sent-events/src/curl_client.hpp | 34 +++++++---- 2 files changed, 52 insertions(+), 45 deletions(-) diff --git a/libs/networking/src/curl_multi_manager.cpp b/libs/networking/src/curl_multi_manager.cpp index 9c9157a80..19b84ec96 100644 --- a/libs/networking/src/curl_multi_manager.cpp +++ b/libs/networking/src/curl_multi_manager.cpp @@ -4,8 +4,6 @@ #include -#include - namespace launchdarkly::network { std::shared_ptr CurlMultiManager::create( boost::asio::any_io_executor executor) { @@ -49,7 +47,8 @@ CurlMultiManager::~CurlMultiManager() { void CurlMultiManager::add_handle(const std::shared_ptr& easy, curl_slist* headers, CompletionCallback callback, - std::optional read_timeout) { + std::optional + read_timeout) { if (const CURLMcode rc = curl_multi_add_handle( multi_handle_.get(), easy.get()); rc != CURLM_OK) { @@ -58,8 +57,6 @@ void CurlMultiManager::add_handle(const std::shared_ptr& easy, curl_slist_free_all(headers); } - std::cerr << "Failed to add handle to multi: " - << curl_multi_strerror(rc) << std::endl; return; } @@ -72,19 +69,21 @@ void CurlMultiManager::add_handle(const std::shared_ptr& easy, // Setup read timeout timer if specified if (read_timeout) { auto timer = std::make_shared(executor_); - handle_timeouts_[easy.get()] = HandleTimeoutInfo{read_timeout, timer}; + handle_timeouts_[easy.get()] = HandleTimeoutInfo{ + read_timeout, timer}; // Start the timeout timer timer->expires_after(*read_timeout); auto weak_self = weak_from_this(); CURL* easy_ptr = easy.get(); - timer->async_wait([weak_self, easy_ptr](const boost::system::error_code& ec) { - if (!ec) { - if (auto self = weak_self.lock()) { - self->handle_read_timeout(easy_ptr); + timer->async_wait( + [weak_self, easy_ptr](const boost::system::error_code& ec) { + if (!ec) { + if (auto self = weak_self.lock()) { + self->handle_read_timeout(easy_ptr); + } } - } - }); + }); } } } @@ -153,14 +152,11 @@ int CurlMultiManager::timer_callback(CURLM* multi, void CurlMultiManager::handle_socket_action(curl_socket_t s, const int event_bitmask) { int running_handles = 0; - const CURLMcode rc = curl_multi_socket_action(multi_handle_.get(), s, - event_bitmask, - &running_handles); - - if (rc != CURLM_OK) { - std::cerr << "curl_multi_socket_action failed: " - << curl_multi_strerror(rc) << std::endl; - } + // This can return an error code, but checking the multi_info will be + // sufficient without additional handling for this error code. + curl_multi_socket_action(multi_handle_.get(), s, + event_bitmask, + &running_handles); check_multi_info(); @@ -191,7 +187,6 @@ void CurlMultiManager::check_multi_info() { callback = std::move(it->second); callbacks_.erase(it); } - callbacks_.erase(easy); // Get and remove headers if (auto header_it = headers_.find(easy); @@ -226,7 +221,8 @@ void CurlMultiManager::check_multi_info() { if (callback) { boost::asio::post(executor_, [callback = std::move(callback), result, handle]() { - callback(handle, Result::FromCurlCode(result)); + callback( + handle, Result::FromCurlCode(result)); }); } } @@ -247,8 +243,6 @@ void CurlMultiManager::start_socket_monitor(SocketInfo* socket_info, socket_info->sockfd, ec); if (ec) { - std::cerr << "Failed to assign socket: " << ec.message() << - std::endl; socket_info->handle.reset(); return; } @@ -368,13 +362,14 @@ void CurlMultiManager::reset_read_timeout(CURL* easy) { auto weak_self = weak_from_this(); CURL* easy_ptr = easy; - timeout_info.timer->async_wait([weak_self, easy_ptr](const boost::system::error_code& ec) { - if (!ec) { - if (auto self = weak_self.lock()) { - self->handle_read_timeout(easy_ptr); + timeout_info.timer->async_wait( + [weak_self, easy_ptr](const boost::system::error_code& ec) { + if (!ec) { + if (auto self = weak_self.lock()) { + self->handle_read_timeout(easy_ptr); + } } - } - }); + }); } } @@ -389,7 +384,7 @@ void CurlMultiManager::handle_read_timeout(CURL* easy) { // Check if handle still exists auto it = callbacks_.find(easy); if (it == callbacks_.end()) { - return; // Handle already completed + return; // Handle already completed } // Get and remove callback @@ -429,9 +424,9 @@ void CurlMultiManager::handle_read_timeout(CURL* easy) { // Invoke completion callback with read timeout result if (callback) { boost::asio::post(executor_, [callback = std::move(callback), - handle]() { - callback(handle, Result::FromReadTimeout()); - }); + handle]() { + callback(handle, Result::FromReadTimeout()); + }); } } } // namespace launchdarkly::network diff --git a/libs/server-sent-events/src/curl_client.hpp b/libs/server-sent-events/src/curl_client.hpp index a493c0f47..4394078b3 100644 --- a/libs/server-sent-events/src/curl_client.hpp +++ b/libs/server-sent-events/src/curl_client.hpp @@ -27,20 +27,22 @@ namespace net = boost::asio; using launchdarkly::network::CurlMultiManager; /** - * The lifecycle of the CurlClient is managed in an RAII manner. This introduces - * some complexity with interaction with CURL, which requires a thread to be - * driven. The desutruction of the CurlClient will signal that the CURL thread - * should stop. Though depending on the operation it may linger for some - * time. - * - * The CurlClient itself is not accessible from the CURL thread and instead - * all their shared state is in a RequestContext. The lifetime of this context - * can exceed that of the CurlClient while CURL shuts down. This approach - * prevents the lifetime of the CurlClient being attached to that of the - * curl thread. + * The CurlClient uses the CURL multi-socket interface to allow for + * single-threaded usage of CURL. We drive this usage using boost::asio + * and every thing is executed in the IO context provided during client + * construction. Calling into the API of the client could be done from threads + * other than the IO context thread, so some thread-safety is required to + * manage those interactions. For example the CurlClient destructor will + * be ran on whatever thread last retains a reference to the client. */ class CurlClient final : public Client, public std::enable_shared_from_this { + /** + * Structure containing callbacks between the CURL interactions and the + * IO executor. Callbacks are set while a connection is being established, + * instead of at construction time, to allow the use of weak_from_self. + * The weak_from_self method cannot be used during the constructor. + */ struct Callbacks { std::function do_backoff; std::function on_receive; @@ -63,6 +65,16 @@ class CurlClient final : public Client, } }; + /** + * The request context represents the state required by the executing CURL + * request. Not directly including the shared data in the CurlClient allows + * for easy seperation of its lifetime from that of the CURL client. This + * facilitates destruction of the CurlClient being used to stop in-progress + * requests. + * + * Also the CURL client can be destructed and pending tasks will still + * have a valid RequestContext and will detect the shutdown. + */ class RequestContext { // Only items used by both the curl thread and the executor/main // thread need to be mutex protected. From 12a7783306e55de95a2da72c14e2db000e64079c Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Sat, 18 Oct 2025 15:43:49 -0700 Subject: [PATCH 84/90] Additional code cleanup. --- libs/internal/tests/curl_requester_test.cpp | 8 +- .../network/curl_multi_manager.hpp | 7 -- libs/server-sent-events/src/curl_client.cpp | 5 - libs/server-sent-events/src/curl_client.hpp | 25 +---- .../tests/curl_client_test.cpp | 97 ++++++------------- 5 files changed, 36 insertions(+), 106 deletions(-) diff --git a/libs/internal/tests/curl_requester_test.cpp b/libs/internal/tests/curl_requester_test.cpp index 223c40afe..734538a89 100644 --- a/libs/internal/tests/curl_requester_test.cpp +++ b/libs/internal/tests/curl_requester_test.cpp @@ -220,7 +220,7 @@ TEST_F(CurlRequesterTest, HandlesCustomHeaders) { server_->SetHandler( [](http::request const& req) -> http::response { - auto header_it = req.find("X-Custom-Header"); + const auto header_it = req.find("X-Custom-Header"); EXPECT_NE(req.end(), header_it); if (header_it != req.end()) { EXPECT_EQ("custom-value", header_it->value()); @@ -275,7 +275,7 @@ TEST_F(CurlRequesterTest, Handles404Status) { }); net::io_context client_ioc; - CurlRequester requester( + const CurlRequester requester( client_ioc.get_executor(), launchdarkly::config::shared::built::TlsOptions()); @@ -304,8 +304,8 @@ TEST_F(CurlRequesterTest, Handles404Status) { TEST_F(CurlRequesterTest, HandlesInvalidUrl) { net::io_context client_ioc; - CurlRequester requester( - client_ioc.get_executor(), + const CurlRequester requester( + client_ioc.get_executor(), launchdarkly::config::shared::built::TlsOptions()); HttpRequest request("not a valid url", HttpMethod::kGet, diff --git a/libs/networking/include/launchdarkly/network/curl_multi_manager.hpp b/libs/networking/include/launchdarkly/network/curl_multi_manager.hpp index ed2dc9b70..fa117a4a6 100644 --- a/libs/networking/include/launchdarkly/network/curl_multi_manager.hpp +++ b/libs/networking/include/launchdarkly/network/curl_multi_manager.hpp @@ -25,12 +25,6 @@ using SocketHandle = boost::asio::ip::tcp::socket; * multi interface with Boost.ASIO. Instead of blocking threads, CURL notifies * us via callbacks when sockets need attention, and we use ASIO to monitor * those sockets asynchronously. - * - * Key features: - * - Non-blocking I/O using curl_multi_socket_action - * - Cross-platform socket monitoring via ASIO tcp::socket - * - Timer integration with ASIO steady_timer - * - Thread-safe operation on ASIO executor */ class CurlMultiManager : public std::enable_shared_from_this { public: @@ -135,7 +129,6 @@ class CurlMultiManager : public std::enable_shared_from_this { }; boost::asio::any_io_executor executor_; - // CURLM* multi_handle_; std::unique_ptr multi_handle_; boost::asio::steady_timer timer_; diff --git a/libs/server-sent-events/src/curl_client.cpp b/libs/server-sent-events/src/curl_client.cpp index c410db004..53ca77071 100644 --- a/libs/server-sent-events/src/curl_client.cpp +++ b/libs/server-sent-events/src/curl_client.cpp @@ -346,11 +346,6 @@ curl_socket_t CurlClient::OpenSocketCallback(void* clientp, curl_socket_t sockfd = socket(address->family, address->socktype, address->protocol); - // Store it so we can close it during shutdown - if (sockfd != CURL_SOCKET_BAD) { - context->set_curl_socket(sockfd); - } - return sockfd; } diff --git a/libs/server-sent-events/src/curl_client.hpp b/libs/server-sent-events/src/curl_client.hpp index 4394078b3..785fa5ab5 100644 --- a/libs/server-sent-events/src/curl_client.hpp +++ b/libs/server-sent-events/src/curl_client.hpp @@ -68,11 +68,11 @@ class CurlClient final : public Client, /** * The request context represents the state required by the executing CURL * request. Not directly including the shared data in the CurlClient allows - * for easy seperation of its lifetime from that of the CURL client. This + * for easy separation of its lifetime from that of the CURL client. This * facilitates destruction of the CurlClient being used to stop in-progress * requests. * - * Also the CURL client can be destructed and pending tasks will still + * The CURL client can be destructed and pending tasks will still * have a valid RequestContext and will detect the shutdown. */ class RequestContext { @@ -80,15 +80,14 @@ class CurlClient final : public Client, // thread need to be mutex protected. std::mutex mutex_; std::atomic shutting_down_; - std::atomic curl_socket_; // End mutex protected items. std::optional callbacks_; public: // SSE parser using common parser from parser.hpp using ParserBody = detail::EventBody>; - std::unique_ptr parser_body; - std::unique_ptr parser_reader; + std::unique_ptr parser_body; + std::unique_ptr parser_reader; // Track last event ID for reconnection (separate from parser state) std::optional last_event_id; @@ -165,24 +164,11 @@ class CurlClient final : public Client, return shutting_down_; } - void set_curl_socket(curl_socket_t curl_socket) { - std::lock_guard lock(mutex_); - curl_socket_ = curl_socket; - } - void shutdown() { std::lock_guard lock(mutex_); shutting_down_ = true; - if (curl_socket_ != CURL_SOCKET_BAD) { -#ifdef _WIN32 - closesocket(curl_socket_); -#else - close(curl_socket_); -#endif - } } - RequestContext(std::string url, http::request req, std::optional connect_timeout, @@ -190,9 +176,8 @@ class CurlClient final : public Client, std::optional write_timeout, std::optional custom_ca_file, std::optional proxy_url, - bool skip_verify_peer + const bool skip_verify_peer ) : shutting_down_(false), - curl_socket_(CURL_SOCKET_BAD), last_download_amount(0), req(std::move(req)), url(std::move(url)), diff --git a/libs/server-sent-events/tests/curl_client_test.cpp b/libs/server-sent-events/tests/curl_client_test.cpp index 08a3d176c..00c75275f 100644 --- a/libs/server-sent-events/tests/curl_client_test.cpp +++ b/libs/server-sent-events/tests/curl_client_test.cpp @@ -8,7 +8,6 @@ #include "mock_sse_server.hpp" #include -#include #include #include @@ -23,12 +22,13 @@ using namespace std::chrono_literals; namespace { // C++17-compatible latch replacement +// https://en.cppreference.com/w/cpp/thread/latch.html class SimpleLatch { public: - explicit SimpleLatch(std::size_t count) : count_(count) {} + explicit SimpleLatch(const std::size_t count) : count_(count) {} void count_down() { - std::lock_guard lock(mutex_); + std::lock_guard lock(mutex_); if (count_ > 0) { --count_; } @@ -37,7 +37,7 @@ class SimpleLatch { template bool wait_for(std::chrono::duration timeout) { - std::unique_lock lock(mutex_); + std::unique_lock lock(mutex_); return cv_.wait_for(lock, timeout, [this] { return count_ == 0; }); } @@ -82,12 +82,6 @@ class EventCollector { return errors_; } - void clear() { - std::lock_guard lock(mutex_); - events_.clear(); - errors_.clear(); - } - private: mutable std::mutex mutex_; std::condition_variable cv_; @@ -456,7 +450,7 @@ TEST(CurlClientTest, Handles500Error) { std::atomic connection_attempts{0}; auto handler = [&](auto const&, auto send_response, auto, auto) { - connection_attempts++; + ++connection_attempts; http::response res{http::status::internal_server_error, 11}; res.body() = "Error"; res.prepare_payload(); @@ -585,7 +579,7 @@ TEST(CurlClientTest, HandlesImmediateClose) { std::atomic connection_attempts{0}; auto handler = [&](auto const&, auto, auto, auto close) { - connection_attempts++; + ++connection_attempts; close(); // Immediately close without sending headers }; @@ -664,59 +658,26 @@ TEST(CurlClientTest, RespectsReadTimeout) { EXPECT_TRUE(shutdown_latch.wait_for(100ms)); } -// Resource management tests - -TEST(CurlClientTest, NoThreadLeaksAfterMultipleConnections) { - // This test verifies that threads are properly joined and not leaked - MockSSEServer server; - auto port = server.start(TestHandlers::simple_event("test")); - - IoContextRunner runner; - - // Create and destroy multiple clients - for (int i = 0; i < 5; i++) { - EventCollector collector; - - auto client = Builder(runner.context().get_executor(), "http://localhost:" + std::to_string(port)) - .receiver([&](Event e) { collector.add_event(std::move(e)); }) - .build(); - - client->async_connect(); - ASSERT_TRUE(collector.wait_for_events(1)); - - SimpleLatch shutdown_latch(1); - client->async_shutdown([&] { shutdown_latch.count_down(); }); - EXPECT_TRUE(shutdown_latch.wait_for(5000ms)); - - // Client should be cleanly destroyed here - } - - // If threads weren't properly joined, we'd likely see issues here - // The test passing indicates proper resource cleanup -} - TEST(CurlClientTest, DestructorCleansUpProperly) { - MockSSEServer server; - auto port = server.start([](auto const&, auto send_response, auto send_sse_event, auto) { - http::response res{http::status::ok, 11}; - res.set(http::field::content_type, "text/event-stream"); - res.chunked(true); - send_response(res); - - // Keep sending events - for (int i = 0; i < 100; i++) { - send_sse_event(SSEFormatter::event("event " + std::to_string(i))); - std::this_thread::sleep_for(10ms); - } - }); - - IoContextRunner runner; - EventCollector collector; - { + MockSSEServer server; + auto port = server.start([](auto const&, auto send_response, auto send_sse_event, auto) { + http::response res{http::status::ok, 11}; + res.set(http::field::content_type, "text/event-stream"); + res.chunked(true); + send_response(res); + + // Keep sending events + for (int i = 0; i < 100; i++) { + send_sse_event(SSEFormatter::event("event " + std::to_string(i))); + std::this_thread::sleep_for(10ms); + } + }); + EventCollector collector; + IoContextRunner runner; auto client = Builder(runner.context().get_executor(), "http://localhost:" + std::to_string(port)) - .receiver([&](Event e) { collector.add_event(std::move(e)); }) - .build(); + .receiver([&](Event e) { collector.add_event(std::move(e)); }) + .build(); client->async_connect(); ASSERT_TRUE(collector.wait_for_events(1)); @@ -728,8 +689,6 @@ TEST(CurlClientTest, DestructorCleansUpProperly) { // Test passing indicates proper cleanup in destructor } -// Edge case tests - TEST(CurlClientTest, HandlesEmptyEventData) { MockSSEServer server; auto port = server.start([](auto const&, auto send_response, auto send_sse_event, auto close) { @@ -798,9 +757,9 @@ TEST(CurlClientTest, HandlesEventWithOnlyType) { TEST(CurlClientTest, HandlesRapidEvents) { MockSSEServer server; - const int num_events = 100; + constexpr int num_events = 100; - auto port = server.start([num_events](auto const&, auto send_response, auto send_sse_event, auto close) { + auto port = server.start([](auto const&, auto send_response, auto send_sse_event, auto close) { http::response res{http::status::ok, 11}; res.set(http::field::content_type, "text/event-stream"); res.chunked(true); @@ -832,14 +791,12 @@ TEST(CurlClientTest, HandlesRapidEvents) { EXPECT_TRUE(shutdown_latch.wait_for(5000ms)); } -// Shutdown-specific tests - critical for preventing crashes/hangs in user applications - TEST(CurlClientTest, ShutdownDuringBackoffDelay) { // This ensures clean shutdown during backoff/retry wait period std::atomic connection_attempts{0}; auto handler = [&](auto const&, auto send_response, auto, auto) { - connection_attempts++; + ++connection_attempts; // Return 500 to trigger backoff http::response res{http::status::internal_server_error, 11}; res.body() = "Error"; @@ -906,7 +863,7 @@ TEST(CurlClientTest, ShutdownDuringDataReception) { IoContextRunner runner; // Shared ptr to prevent handling events during destruction. - std::shared_ptr collector = std::make_shared(); + auto collector = std::make_shared(); auto client = Builder(runner.context().get_executor(), "http://localhost:" + std::to_string(port)) .receiver([collector, &client_received_some](Event e) { From a80d0a554f768ab126f261386be08561dbfe4ed5 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Sat, 18 Oct 2025 15:50:00 -0700 Subject: [PATCH 85/90] Keep socket close logic. --- libs/server-sent-events/src/curl_client.cpp | 5 +++++ libs/server-sent-events/src/curl_client.hpp | 17 ++++++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/libs/server-sent-events/src/curl_client.cpp b/libs/server-sent-events/src/curl_client.cpp index 53ca77071..c410db004 100644 --- a/libs/server-sent-events/src/curl_client.cpp +++ b/libs/server-sent-events/src/curl_client.cpp @@ -346,6 +346,11 @@ curl_socket_t CurlClient::OpenSocketCallback(void* clientp, curl_socket_t sockfd = socket(address->family, address->socktype, address->protocol); + // Store it so we can close it during shutdown + if (sockfd != CURL_SOCKET_BAD) { + context->set_curl_socket(sockfd); + } + return sockfd; } diff --git a/libs/server-sent-events/src/curl_client.hpp b/libs/server-sent-events/src/curl_client.hpp index 785fa5ab5..a3ab9e173 100644 --- a/libs/server-sent-events/src/curl_client.hpp +++ b/libs/server-sent-events/src/curl_client.hpp @@ -80,6 +80,7 @@ class CurlClient final : public Client, // thread need to be mutex protected. std::mutex mutex_; std::atomic shutting_down_; + std::atomic curl_socket_; // End mutex protected items. std::optional callbacks_; @@ -164,11 +165,24 @@ class CurlClient final : public Client, return shutting_down_; } + void set_curl_socket(curl_socket_t curl_socket) { + std::lock_guard lock(mutex_); + curl_socket_ = curl_socket; + } + void shutdown() { std::lock_guard lock(mutex_); shutting_down_ = true; + if (curl_socket_ != CURL_SOCKET_BAD) { +#ifdef _WIN32 + closesocket(curl_socket_); +#else + close(curl_socket_); +#endif + } } + RequestContext(std::string url, http::request req, std::optional connect_timeout, @@ -176,8 +190,9 @@ class CurlClient final : public Client, std::optional write_timeout, std::optional custom_ca_file, std::optional proxy_url, - const bool skip_verify_peer + bool skip_verify_peer ) : shutting_down_(false), + curl_socket_(CURL_SOCKET_BAD), last_download_amount(0), req(std::move(req)), url(std::move(url)), From 93c498a769ee6265e9aa307e1884cdd2448c2e86 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Sat, 18 Oct 2025 16:01:19 -0700 Subject: [PATCH 86/90] Explicit capture for num_events for MSVC. --- examples/hello-cpp-client/main.cpp | 17 ++++++++++++++--- .../tests/curl_client_test.cpp | 3 ++- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/examples/hello-cpp-client/main.cpp b/examples/hello-cpp-client/main.cpp index 3ac7c99ea..686769c2f 100644 --- a/examples/hello-cpp-client/main.cpp +++ b/examples/hello-cpp-client/main.cpp @@ -53,14 +53,25 @@ int main() { } } else { std::cout << "*** SDK initialization didn't complete in " - << INIT_TIMEOUT_MILLISECONDS << "ms\n"; + << INIT_TIMEOUT_MILLISECONDS << "ms\n"; return 1; } bool const flag_value = client.BoolVariation(FEATURE_FLAG_KEY, false); std::cout << "*** Feature flag '" << FEATURE_FLAG_KEY << "' is " - << (flag_value ? "true" : "false") << " for this user\n\n"; + << (flag_value ? "true" : "false") << " for this user\n\n"; + + client.FlagNotifier().OnFlagChange("my-boolean-flag", + [](const std::shared_ptr< + flag_manager::FlagValueChangeEvent> + & change_event) { + std::cout << "my-boolean-flag changed! " << change_event->NewValue() << std::endl; + }); + + // Keep it running. + std::string in; + std::cin >> in; return 0; } @@ -79,4 +90,4 @@ char const* get_with_env_fallback(char const* source_val, std::cout << "*** " << error_msg << std::endl; std::exit(1); -} +} \ No newline at end of file diff --git a/libs/server-sent-events/tests/curl_client_test.cpp b/libs/server-sent-events/tests/curl_client_test.cpp index 00c75275f..5ac9476c7 100644 --- a/libs/server-sent-events/tests/curl_client_test.cpp +++ b/libs/server-sent-events/tests/curl_client_test.cpp @@ -759,7 +759,8 @@ TEST(CurlClientTest, HandlesRapidEvents) { MockSSEServer server; constexpr int num_events = 100; - auto port = server.start([](auto const&, auto send_response, auto send_sse_event, auto close) { + // num_events needs to be captured for MSVC. + auto port = server.start([num_events](auto const&, auto send_response, auto send_sse_event, auto close) { http::response res{http::status::ok, 11}; res.set(http::field::content_type, "text/event-stream"); res.chunked(true); From 8ee6a0d08e41f4a18c52c5a7e325aafe55585797 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Sat, 18 Oct 2025 17:27:38 -0700 Subject: [PATCH 87/90] Remove handlers that CURL no longer needs. --- examples/hello-cpp-client/main.cpp | 2 +- libs/networking/src/curl_multi_manager.cpp | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/examples/hello-cpp-client/main.cpp b/examples/hello-cpp-client/main.cpp index 686769c2f..47dbae380 100644 --- a/examples/hello-cpp-client/main.cpp +++ b/examples/hello-cpp-client/main.cpp @@ -12,7 +12,7 @@ // Set INIT_TIMEOUT_MILLISECONDS to the amount of time you will wait for // the client to become initialized. -#define INIT_TIMEOUT_MILLISECONDS 3000 +#define INIT_TIMEOUT_MILLISECONDS 9999999999 char const* get_with_env_fallback(char const* source_val, char const* env_variable, diff --git a/libs/networking/src/curl_multi_manager.cpp b/libs/networking/src/curl_multi_manager.cpp index 19b84ec96..a7babd077 100644 --- a/libs/networking/src/curl_multi_manager.cpp +++ b/libs/networking/src/curl_multi_manager.cpp @@ -2,6 +2,7 @@ #include "launchdarkly/network/curl_multi_manager.hpp" +#include #include namespace launchdarkly::network { @@ -93,6 +94,8 @@ int CurlMultiManager::socket_callback(CURL* easy, int what, void* userp, void* socketp) { + + std::cout << "socket_callback: what: " << what << " socket: " << s << std::endl; auto* manager = static_cast(userp); std::lock_guard lock(manager->mutex_); @@ -101,6 +104,7 @@ int CurlMultiManager::socket_callback(CURL* easy, manager->reset_read_timeout(easy); if (what == CURL_POLL_REMOVE) { + std::cout << "socket_callback: remove: " << s << std::endl; // Remove socket from managed container if (const auto it = manager->sockets_.find(s); it != manager->sockets_.end()) { @@ -257,6 +261,7 @@ void CurlMultiManager::start_socket_monitor(SocketInfo* socket_info, // Monitor for read events if (action & CURL_POLL_IN) { + std::cout << "start_socket_monitor: poll in: " << socket_info->sockfd << std::endl; // Only create new handler if we don't have one or if action changed if (!socket_info->read_handler || action_changed) { // Use weak_ptr to safely detect when handle is deleted @@ -301,10 +306,13 @@ void CurlMultiManager::start_socket_monitor(SocketInfo* socket_info, }; (*socket_info->read_handler)(); // Initial call } + } else { + socket_info->read_handler.reset(); } // Monitor for write events if (action & CURL_POLL_OUT) { + std::cout << "start_socket_monitor: poll out: " << socket_info->sockfd << std::endl; // Only create new handler if we don't have one or if action changed if (!socket_info->write_handler || action_changed) { // Use weak_ptr to safely detect when handle is deleted @@ -349,6 +357,8 @@ void CurlMultiManager::start_socket_monitor(SocketInfo* socket_info, }; (*socket_info->write_handler)(); // Initial call } + } else { + socket_info->write_handler.reset(); } } From b3e35baf9070821f0cc8678661a30e08d1e26de2 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Sat, 18 Oct 2025 17:29:54 -0700 Subject: [PATCH 88/90] Undo hello app changes. --- examples/hello-cpp-client/main.cpp | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/examples/hello-cpp-client/main.cpp b/examples/hello-cpp-client/main.cpp index 47dbae380..3ac7c99ea 100644 --- a/examples/hello-cpp-client/main.cpp +++ b/examples/hello-cpp-client/main.cpp @@ -12,7 +12,7 @@ // Set INIT_TIMEOUT_MILLISECONDS to the amount of time you will wait for // the client to become initialized. -#define INIT_TIMEOUT_MILLISECONDS 9999999999 +#define INIT_TIMEOUT_MILLISECONDS 3000 char const* get_with_env_fallback(char const* source_val, char const* env_variable, @@ -53,25 +53,14 @@ int main() { } } else { std::cout << "*** SDK initialization didn't complete in " - << INIT_TIMEOUT_MILLISECONDS << "ms\n"; + << INIT_TIMEOUT_MILLISECONDS << "ms\n"; return 1; } bool const flag_value = client.BoolVariation(FEATURE_FLAG_KEY, false); std::cout << "*** Feature flag '" << FEATURE_FLAG_KEY << "' is " - << (flag_value ? "true" : "false") << " for this user\n\n"; - - client.FlagNotifier().OnFlagChange("my-boolean-flag", - [](const std::shared_ptr< - flag_manager::FlagValueChangeEvent> - & change_event) { - std::cout << "my-boolean-flag changed! " << change_event->NewValue() << std::endl; - }); - - // Keep it running. - std::string in; - std::cin >> in; + << (flag_value ? "true" : "false") << " for this user\n\n"; return 0; } @@ -90,4 +79,4 @@ char const* get_with_env_fallback(char const* source_val, std::cout << "*** " << error_msg << std::endl; std::exit(1); -} \ No newline at end of file +} From 718f7be2c392bfdac298f29056286cf6c769489c Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Sat, 18 Oct 2025 18:09:47 -0700 Subject: [PATCH 89/90] Remove debug logs. --- libs/networking/src/curl_multi_manager.cpp | 5 ----- 1 file changed, 5 deletions(-) diff --git a/libs/networking/src/curl_multi_manager.cpp b/libs/networking/src/curl_multi_manager.cpp index a7babd077..c2b79cbb1 100644 --- a/libs/networking/src/curl_multi_manager.cpp +++ b/libs/networking/src/curl_multi_manager.cpp @@ -2,7 +2,6 @@ #include "launchdarkly/network/curl_multi_manager.hpp" -#include #include namespace launchdarkly::network { @@ -95,7 +94,6 @@ int CurlMultiManager::socket_callback(CURL* easy, void* userp, void* socketp) { - std::cout << "socket_callback: what: " << what << " socket: " << s << std::endl; auto* manager = static_cast(userp); std::lock_guard lock(manager->mutex_); @@ -104,7 +102,6 @@ int CurlMultiManager::socket_callback(CURL* easy, manager->reset_read_timeout(easy); if (what == CURL_POLL_REMOVE) { - std::cout << "socket_callback: remove: " << s << std::endl; // Remove socket from managed container if (const auto it = manager->sockets_.find(s); it != manager->sockets_.end()) { @@ -261,7 +258,6 @@ void CurlMultiManager::start_socket_monitor(SocketInfo* socket_info, // Monitor for read events if (action & CURL_POLL_IN) { - std::cout << "start_socket_monitor: poll in: " << socket_info->sockfd << std::endl; // Only create new handler if we don't have one or if action changed if (!socket_info->read_handler || action_changed) { // Use weak_ptr to safely detect when handle is deleted @@ -312,7 +308,6 @@ void CurlMultiManager::start_socket_monitor(SocketInfo* socket_info, // Monitor for write events if (action & CURL_POLL_OUT) { - std::cout << "start_socket_monitor: poll out: " << socket_info->sockfd << std::endl; // Only create new handler if we don't have one or if action changed if (!socket_info->write_handler || action_changed) { // Use weak_ptr to safely detect when handle is deleted From 75c79b816c1cbbb6f89b97ec2d28fee04fe10bf8 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 22 Oct 2025 13:56:55 -0700 Subject: [PATCH 90/90] Update examples/proxy-validation-test/README.md Co-authored-by: Matthew M. Keeler --- examples/proxy-validation-test/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/proxy-validation-test/README.md b/examples/proxy-validation-test/README.md index f22ecbe82..76e0877bc 100644 --- a/examples/proxy-validation-test/README.md +++ b/examples/proxy-validation-test/README.md @@ -16,7 +16,7 @@ This test validates that the LaunchDarkly C++ Client SDK properly uses CURL for Polling can be validated by changing the hello-cpp-client application to use polling. -## Test Architecturegit +## Test Architecture The test uses Docker Compose with network isolation to validate proxy functionality: