From b90a843dddeb6df2acdc6a04b74386cbb892e8ff Mon Sep 17 00:00:00 2001 From: Bart Date: Wed, 28 Jan 2026 10:02:34 +0000 Subject: [PATCH 1/2] ci: Upload Conan recipes for develop, release candidates, and releases (#6286) To allow developers to consume the latest unstable and (near-)stable versions of our `xrpl` Conan recipe, we should export and upload it whenever a push occurs to the corresponding branch or a release tag has been created. This way, developers do not have to figure out themselves what the most recent shortened commit hash was to determine the latest unstable recipe version (e.g. `3.2.0-b0+a1b2c3d`) or what the most recent release (candidate) was to determine the latest (near-)stable recipe version (e.g. `3.1.0-rc2`). Now, pushes to the `develop` branch will produce the `develop` recipe version, pushes to the `release` branch will produce the `rc` recipe version, and creation of versioned tags will produce the `release` recipe version. --- .github/workflows/reusable-upload-recipe.yml | 26 +++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/.github/workflows/reusable-upload-recipe.yml b/.github/workflows/reusable-upload-recipe.yml index 156659392e..79af516fb3 100644 --- a/.github/workflows/reusable-upload-recipe.yml +++ b/.github/workflows/reusable-upload-recipe.yml @@ -62,12 +62,36 @@ jobs: REMOTE_PASSWORD: ${{ secrets.remote_password }} run: conan remote login "${REMOTE_NAME}" "${REMOTE_USERNAME}" --password "${REMOTE_PASSWORD}" - - name: Upload Conan recipe + - name: Upload Conan recipe (version) env: REMOTE_NAME: ${{ inputs.remote_name }} run: | conan export . --version=${{ steps.version.outputs.version }} conan upload --confirm --check --remote="${REMOTE_NAME}" xrpl/${{ steps.version.outputs.version }} + - name: Upload Conan recipe (develop) + if: ${{ github.ref == 'refs/heads/develop' }} + env: + REMOTE_NAME: ${{ inputs.remote_name }} + run: | + conan export . --version=develop + conan upload --confirm --check --remote="${REMOTE_NAME}" xrpl/develop + + - name: Upload Conan recipe (rc) + if: ${{ startsWith(github.ref, 'refs/heads/release') }} + env: + REMOTE_NAME: ${{ inputs.remote_name }} + run: | + conan export . --version=rc + conan upload --confirm --check --remote="${REMOTE_NAME}" xrpl/rc + + - name: Upload Conan recipe (release) + if: ${{ github.event_name == 'tag' }} + env: + REMOTE_NAME: ${{ inputs.remote_name }} + run: | + conan export . --version=release + conan upload --confirm --check --remote="${REMOTE_NAME}" xrpl/release + outputs: ref: xrpl/${{ steps.version.outputs.version }} From 92046785d1fea5f9efe5a770d636792ea6cab78b Mon Sep 17 00:00:00 2001 From: Jingchen Date: Wed, 28 Jan 2026 15:14:35 +0000 Subject: [PATCH 2/2] test: Fix the `xrpl.net` unit test using async read (#6241) This change makes the `read` function call in `handleConnection` async, adds a new class `TestSink` to help debugging, and adds a new target `xrpl.tests.helpers` to put the helper class in. --- src/tests/libxrpl/CMakeLists.txt | 12 +- src/tests/libxrpl/helpers/TestSink.cpp | 127 ++++++++++++ src/tests/libxrpl/helpers/TestSink.h | 27 +++ src/tests/libxrpl/net/HTTPClient.cpp | 273 ++++++++++++++----------- 4 files changed, 313 insertions(+), 126 deletions(-) create mode 100644 src/tests/libxrpl/helpers/TestSink.cpp create mode 100644 src/tests/libxrpl/helpers/TestSink.h diff --git a/src/tests/libxrpl/CMakeLists.txt b/src/tests/libxrpl/CMakeLists.txt index 74dc184700..72d7d0fa92 100644 --- a/src/tests/libxrpl/CMakeLists.txt +++ b/src/tests/libxrpl/CMakeLists.txt @@ -6,9 +6,19 @@ find_package(GTest REQUIRED) # Custom target for all tests defined in this file add_custom_target(xrpl.tests) +# Test helpers +add_library(xrpl.helpers.test STATIC) +target_sources(xrpl.helpers.test PRIVATE + helpers/TestSink.cpp +) +target_include_directories(xrpl.helpers.test PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR} +) +target_link_libraries(xrpl.helpers.test PRIVATE xrpl.libxrpl) + # Common library dependencies for the rest of the tests. add_library(xrpl.imports.test INTERFACE) -target_link_libraries(xrpl.imports.test INTERFACE gtest::gtest xrpl.libxrpl) +target_link_libraries(xrpl.imports.test INTERFACE gtest::gtest xrpl.libxrpl xrpl.helpers.test) # One test for each module. xrpl_add_test(basics) diff --git a/src/tests/libxrpl/helpers/TestSink.cpp b/src/tests/libxrpl/helpers/TestSink.cpp new file mode 100644 index 0000000000..a71e9f7cc0 --- /dev/null +++ b/src/tests/libxrpl/helpers/TestSink.cpp @@ -0,0 +1,127 @@ +#include + +#include + +#include // for getenv + +#if BOOST_OS_WINDOWS +#include // for _isatty, _fileno +#include // for stdout +#else +#include // for isatty, STDOUT_FILENO +#endif + +#include + +namespace xrpl { + +TestSink::TestSink(beast::severities::Severity threshold) + : Sink(threshold, false) +{ +} + +void +TestSink::write(beast::severities::Severity level, std::string const& text) +{ + if (level < threshold()) + return; + writeAlways(level, text); +} + +void +TestSink::writeAlways( + beast::severities::Severity level, + std::string const& text) +{ + auto supportsColor = [] { + // 1. Check for "NO_COLOR" environment variable (Standard convention) + if (std::getenv("NO_COLOR") != nullptr) + { + return false; + } + + // 2. Check for "CLICOLOR_FORCE" (Force color) + if (std::getenv("CLICOLOR_FORCE") != nullptr) + { + return true; + } + + // 3. Platform-specific check to see if stdout is a terminal +#if BOOST_OS_WINDOWS + // Windows: Check if the output handle is a character device + // _fileno(stdout) is usually 1 + // _isatty returns non-zero if the handle is a character device, 0 + // otherwise. + return _isatty(_fileno(stdout)) != 0; +#else + // Linux/macOS: Check if file descriptor 1 (stdout) is a TTY + // STDOUT_FILENO is 1 + // isatty returns 1 if the file descriptor is a TTY, 0 otherwise. + return isatty(STDOUT_FILENO) != 0; +#endif + }(); + + auto color = [level]() { + switch (level) + { + case beast::severities::kTrace: + return "\033[34m"; // blue + case beast::severities::kDebug: + return "\033[32m"; // green + case beast::severities::kInfo: + return "\033[36m"; // cyan + case beast::severities::kWarning: + return "\033[33m"; // yellow + case beast::severities::kError: + return "\033[31m"; // red + case beast::severities::kFatal: + default: + break; + } + return "\033[31m"; // red + }(); + + auto prefix = [level]() { + switch (level) + { + case beast::severities::kTrace: + return "TRC:"; + case beast::severities::kDebug: + return "DBG:"; + case beast::severities::kInfo: + return "INF:"; + case beast::severities::kWarning: + return "WRN:"; + case beast::severities::kError: + return "ERR:"; + case beast::severities::kFatal: + default: + break; + } + return "FTL:"; + }(); + + auto& stream = [level]() -> std::ostream& { + switch (level) + { + case beast::severities::kError: + case beast::severities::kFatal: + return std::cerr; + default: + return std::cout; + } + }(); + + constexpr auto reset = "\033[0m"; + + if (supportsColor) + { + stream << color << prefix << " " << text << reset << std::endl; + } + else + { + stream << prefix << " " << text << std::endl; + } +} + +} // namespace xrpl diff --git a/src/tests/libxrpl/helpers/TestSink.h b/src/tests/libxrpl/helpers/TestSink.h new file mode 100644 index 0000000000..fc3223b04b --- /dev/null +++ b/src/tests/libxrpl/helpers/TestSink.h @@ -0,0 +1,27 @@ +#ifndef XRPL_DEBUGSINK_H +#define XRPL_DEBUGSINK_H + +#include + +namespace xrpl { +class TestSink : public beast::Journal::Sink +{ +public: + static TestSink& + instance() + { + static TestSink sink{}; + return sink; + } + + TestSink(beast::severities::Severity threshold = beast::severities::kDebug); + + void + write(beast::severities::Severity level, std::string const& text) override; + + void + writeAlways(beast::severities::Severity level, std::string const& text) + override; +}; +} // namespace xrpl +#endif // XRPL_DEBUGSINK_H diff --git a/src/tests/libxrpl/net/HTTPClient.cpp b/src/tests/libxrpl/net/HTTPClient.cpp index cfd206edde..5ff0bfc336 100644 --- a/src/tests/libxrpl/net/HTTPClient.cpp +++ b/src/tests/libxrpl/net/HTTPClient.cpp @@ -2,15 +2,22 @@ #include #include +#include +#include #include +#include +#include #include #include #include #include +#include #include #include +#include +#include #include using namespace xrpl; @@ -24,16 +31,19 @@ private: boost::asio::io_context ioc_; boost::asio::ip::tcp::acceptor acceptor_; boost::asio::ip::tcp::endpoint endpoint_; - std::atomic running_{true}; + bool running_{true}; + bool finished_{false}; unsigned short port_; // Custom headers to return - std::map custom_headers_; - std::string response_body_; - unsigned int status_code_{200}; + std::map customHeaders_; + std::string responseBody_; + unsigned int statusCode_{200}; + + beast::Journal j_; public: - TestHTTPServer() : acceptor_(ioc_), port_(0) + TestHTTPServer() : acceptor_(ioc_), port_(0), j_(TestSink::instance()) { // Bind to any available port endpoint_ = {boost::asio::ip::tcp::v4(), 0}; @@ -45,12 +55,19 @@ public: // Get the actual port that was assigned port_ = acceptor_.local_endpoint().port(); - accept(); + // Start the accept coroutine + boost::asio::co_spawn(ioc_, accept(), boost::asio::detached); } + TestHTTPServer(TestHTTPServer&&) = delete; + TestHTTPServer& + operator=(TestHTTPServer&&) = delete; + ~TestHTTPServer() { - stop(); + XRPL_ASSERT( + finished(), + "xrpl::TestHTTPServer::~TestHTTPServer : accept future ready"); } boost::asio::io_context& @@ -68,22 +85,21 @@ public: void setHeader(std::string const& name, std::string const& value) { - custom_headers_[name] = value; + customHeaders_[name] = value; } void setResponseBody(std::string const& body) { - response_body_ = body; + responseBody_ = body; } void setStatusCode(unsigned int code) { - status_code_ = code; + statusCode_ = code; } -private: void stop() { @@ -91,78 +107,84 @@ private: acceptor_.close(); } - void - accept() + bool + finished() const { - if (!running_) - return; - - acceptor_.async_accept( - ioc_, - endpoint_, - [&](boost::system::error_code const& error, - boost::asio::ip::tcp::socket peer) { - if (!running_) - return; - - if (!error) - { - handleConnection(std::move(peer)); - } - }); + return finished_; } - void +private: + boost::asio::awaitable + accept() + { + while (running_) + { + try + { + auto socket = + co_await acceptor_.async_accept(boost::asio::use_awaitable); + + if (!running_) + break; + + // Handle this connection + co_await handleConnection(std::move(socket)); + } + catch (std::exception const& e) + { + // Accept or handle failed, stop accepting + JLOG(j_.debug()) << "Error: " << e.what(); + break; + } + } + + finished_ = true; + } + + boost::asio::awaitable handleConnection(boost::asio::ip::tcp::socket socket) { try { - // Read the HTTP request boost::beast::flat_buffer buffer; boost::beast::http::request req; - boost::beast::http::read(socket, buffer, req); + + // Read the HTTP request asynchronously + co_await boost::beast::http::async_read( + socket, buffer, req, boost::asio::use_awaitable); // Create response boost::beast::http::response res; res.version(req.version()); - res.result(status_code_); + res.result(statusCode_); res.set(boost::beast::http::field::server, "TestServer"); - // Add custom headers - for (auto const& [name, value] : custom_headers_) + // Set body and prepare payload first + res.body() = responseBody_; + res.prepare_payload(); + + // Override Content-Length with custom headers after + // prepare_payload. This allows us to test case-insensitive + // header parsing. + for (auto const& [name, value] : customHeaders_) { res.set(name, value); } - // Set body and prepare payload first - res.body() = response_body_; - res.prepare_payload(); - - // Override Content-Length with custom headers after prepare_payload - // This allows us to test case-insensitive header parsing - for (auto const& [name, value] : custom_headers_) - { - if (boost::iequals(name, "Content-Length")) - { - res.erase(boost::beast::http::field::content_length); - res.set(name, value); - } - } - - // Send response - boost::beast::http::write(socket, res); + // Send response asynchronously + co_await boost::beast::http::async_write( + socket, res, boost::asio::use_awaitable); // Shutdown socket gracefully - boost::system::error_code ec; - socket.shutdown(boost::asio::ip::tcp::socket::shutdown_send, ec); + boost::system::error_code shutdownEc; + socket.shutdown( + boost::asio::ip::tcp::socket::shutdown_send, shutdownEc); } - catch (std::exception const&) + catch (std::exception const& e) { - // Connection handling errors are expected + // Error reading or writing, just close the connection + JLOG(j_.debug()) << "Connection error: " << e.what(); } - - if (running_) - accept(); } }; @@ -171,13 +193,13 @@ bool runHTTPTest( TestHTTPServer& server, std::string const& path, - std::atomic& completed, - std::atomic& result_status, - std::string& result_data, - boost::system::error_code& result_error) + bool& completed, + int& resultStatus, + std::string& resultData, + boost::system::error_code& resultError) { // Create a null journal for testing - beast::Journal j{beast::Journal::getNullSink()}; + beast::Journal j{TestSink::instance()}; // Initialize HTTPClient SSL context HTTPClient::initializeSSLContext("", "", false, j); @@ -193,9 +215,9 @@ runHTTPTest( [&](boost::system::error_code const& ec, int status, std::string const& data) -> bool { - result_error = ec; - result_status = status; - result_data = data; + resultError = ec; + resultStatus = status; + resultData = data; completed = true; return false; // don't retry }, @@ -203,13 +225,19 @@ runHTTPTest( // Run the IO context until completion auto start = std::chrono::steady_clock::now(); - while (!completed && - std::chrono::steady_clock::now() - start < std::chrono::seconds(10)) + while (server.ioc().run_one() != 0) { - if (server.ioc().run_one() == 0) + if (std::chrono::steady_clock::now() - start >= + std::chrono::seconds(10) || + server.finished()) { break; } + + if (completed) + { + server.stop(); + } } return completed; @@ -220,7 +248,7 @@ runHTTPTest( TEST(HTTPClient, case_insensitive_content_length) { // Test different cases of Content-Length header - std::vector header_cases = { + std::vector headerCases = { "Content-Length", // Standard case "content-length", // Lowercase - this tests the regex icase fix "CONTENT-LENGTH", // Uppercase @@ -228,53 +256,48 @@ TEST(HTTPClient, case_insensitive_content_length) "content-Length" // Mixed case 2 }; - for (auto const& header_name : header_cases) + for (auto const& headerName : headerCases) { TestHTTPServer server; - std::string test_body = "Hello World!"; - server.setResponseBody(test_body); - server.setHeader(header_name, std::to_string(test_body.size())); + std::string testBody = "Hello World!"; + server.setResponseBody(testBody); + server.setHeader(headerName, std::to_string(testBody.size())); - std::atomic completed{false}; - std::atomic result_status{0}; - std::string result_data; - boost::system::error_code result_error; + bool completed{false}; + int resultStatus{0}; + std::string resultData; + boost::system::error_code resultError; - bool test_completed = runHTTPTest( - server, - "/test", - completed, - result_status, - result_data, - result_error); + bool testCompleted = runHTTPTest( + server, "/test", completed, resultStatus, resultData, resultError); // Verify results - EXPECT_TRUE(test_completed); - EXPECT_FALSE(result_error); - EXPECT_EQ(result_status, 200); - EXPECT_EQ(result_data, test_body); + EXPECT_TRUE(testCompleted); + EXPECT_FALSE(resultError); + EXPECT_EQ(resultStatus, 200); + EXPECT_EQ(resultData, testBody); } } TEST(HTTPClient, basic_http_request) { TestHTTPServer server; - std::string test_body = "Test response body"; - server.setResponseBody(test_body); + std::string testBody = "Test response body"; + server.setResponseBody(testBody); server.setHeader("Content-Type", "text/plain"); - std::atomic completed{false}; - std::atomic result_status{0}; - std::string result_data; - boost::system::error_code result_error; + bool completed{false}; + int resultStatus{0}; + std::string resultData; + boost::system::error_code resultError; - bool test_completed = runHTTPTest( - server, "/basic", completed, result_status, result_data, result_error); + bool testCompleted = runHTTPTest( + server, "/basic", completed, resultStatus, resultData, resultError); - EXPECT_TRUE(test_completed); - EXPECT_FALSE(result_error); - EXPECT_EQ(result_status, 200); - EXPECT_EQ(result_data, test_body); + EXPECT_TRUE(testCompleted); + EXPECT_FALSE(resultError); + EXPECT_EQ(resultStatus, 200); + EXPECT_EQ(resultData, testBody); } TEST(HTTPClient, empty_response) @@ -283,45 +306,45 @@ TEST(HTTPClient, empty_response) server.setResponseBody(""); // Empty body server.setHeader("Content-Length", "0"); - std::atomic completed{false}; - std::atomic result_status{0}; - std::string result_data; - boost::system::error_code result_error; + bool completed{false}; + int resultStatus{0}; + std::string resultData; + boost::system::error_code resultError; - bool test_completed = runHTTPTest( - server, "/empty", completed, result_status, result_data, result_error); + bool testCompleted = runHTTPTest( + server, "/empty", completed, resultStatus, resultData, resultError); - EXPECT_TRUE(test_completed); - EXPECT_FALSE(result_error); - EXPECT_EQ(result_status, 200); - EXPECT_TRUE(result_data.empty()); + EXPECT_TRUE(testCompleted); + EXPECT_FALSE(resultError); + EXPECT_EQ(resultStatus, 200); + EXPECT_TRUE(resultData.empty()); } TEST(HTTPClient, different_status_codes) { - std::vector status_codes = {200, 404, 500}; + std::vector statusCodes = {200, 404, 500}; - for (auto status : status_codes) + for (auto status : statusCodes) { TestHTTPServer server; server.setStatusCode(status); server.setResponseBody("Status " + std::to_string(status)); - std::atomic completed{false}; - std::atomic result_status{0}; - std::string result_data; - boost::system::error_code result_error; + bool completed{false}; + int resultStatus{0}; + std::string resultData; + boost::system::error_code resultError; - bool test_completed = runHTTPTest( + bool testCompleted = runHTTPTest( server, "/status", completed, - result_status, - result_data, - result_error); + resultStatus, + resultData, + resultError); - EXPECT_TRUE(test_completed); - EXPECT_FALSE(result_error); - EXPECT_EQ(result_status, static_cast(status)); + EXPECT_TRUE(testCompleted); + EXPECT_FALSE(resultError); + EXPECT_EQ(resultStatus, static_cast(status)); } }