From ca7872fa2dd05bb041eaad2bfe1d410612a3f8a4 Mon Sep 17 00:00:00 2001 From: Nicholas Dudfield Date: Wed, 16 Sep 2026 13:27:29 +0700 Subject: [PATCH] test: cover overlay lifecycle and bound upgrade responses Exercise real ConnectAttempt refusal, TLS handshake failure and timeout, HTTP rejection and timeout, and explicit stop. Cover PeerImp stop and graceful TLS shutdown with real loopback TLS streams, peerfinder slots, and bounded io_context runs. Rearm the ConnectAttempt deadline before reading the HTTP upgrade response. This missing deadline predates Boost port #5570; the new stalled-response regression targets it. The real 15-second handshake and response timers run concurrently. Formatting and whitespace checks pass. Build, runtime control, and coverage measurement are delegated to Claude using the existing release build. (cherry picked from commit 28b8bc1a217e60284666417c9a45525bb14f86bd) --- src/test/overlay/OverlayLifecycle_test.cpp | 412 ++++++++++++++++++++ src/xrpld/overlay/detail/ConnectAttempt.cpp | 3 + 2 files changed, 415 insertions(+) create mode 100644 src/test/overlay/OverlayLifecycle_test.cpp diff --git a/src/test/overlay/OverlayLifecycle_test.cpp b/src/test/overlay/OverlayLifecycle_test.cpp new file mode 100644 index 0000000000..de17c72a0b --- /dev/null +++ b/src/test/overlay/OverlayLifecycle_test.cpp @@ -0,0 +1,412 @@ +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace ripple { +namespace test { + +class OverlayLifecycle_test : public beast::unit_test::suite +{ + using tcp = boost::asio::ip::tcp; + using error_code = boost::system::error_code; + using stream_type = boost::beast::ssl_stream; + + std::shared_ptr context_ = make_SSLContext(""); + + enum class Behavior { + abortHandshake, + stallHandshake, + reject, + stallResponse + }; + + // A single local endpoint on the same explicitly pumped context as the + // real ConnectAttempt. No server thread or external network is needed. + struct Listener + { + Behavior behavior; + tcp::acceptor acceptor; + boost::asio::ssl::stream stream; + boost::beast::flat_buffer buffer; + boost::beast::http::request request; + boost::beast::http::response response; + std::array drainBuffer; + bool accepted = false; + bool handshakeBytes = false; + bool handshaken = false; + bool requested = false; + bool closedByClient = false; + + Listener( + boost::asio::io_context& ioc, + boost::asio::ssl::context& context, + Behavior mode) + : behavior(mode) + , acceptor(ioc, {boost::asio::ip::make_address("127.0.0.1"), 0}) + , stream(ioc, context) + { + acceptor.async_accept(stream.next_layer(), [this](error_code ec) { + if (ec) + return; + accepted = true; + if (behavior == Behavior::abortHandshake) + { + stream.next_layer().close(ec); + return; + } + if (behavior == Behavior::stallHandshake) + return drain(); + stream.async_handshake( + boost::asio::ssl::stream_base::server, + [this](error_code ec) { + if (ec) + return; + handshaken = true; + boost::beast::http::async_read( + stream, + buffer, + request, + [this](error_code ec, std::size_t) { + if (ec) + return; + requested = true; + if (behavior == Behavior::stallResponse) + return drain(); + response.version(11); + response.result(boost::beast::http::status:: + service_unavailable); + response.body() = R"({"peer-ips":[]})"; + response.prepare_payload(); + boost::beast::http::async_write( + stream, + response, + [this](error_code ec, std::size_t) { + if (!ec) + drain(); + }); + }); + }); + }); + } + + tcp::endpoint + endpoint() const + { + return acceptor.local_endpoint(); + } + + void + drain() + { + // Consume transport bytes without sending any TLS or HTTP reply. + // EOF/reset proves that the attempt itself closed the connection. + stream.next_layer().async_read_some( + boost::asio::buffer(drainBuffer), + [this](error_code ec, std::size_t n) { + if (ec) + { + closedByClient = true; + return; + } + handshakeBytes = handshakeBytes || n != 0; + drain(); + }); + } + + void + stop() + { + error_code ec; + acceptor.close(ec); + stream.next_layer().close(ec); + } + }; + + template + bool + runUntil(boost::asio::io_context& ioc, Predicate const& ready) + { + ioc.restart(); + auto const limit = + std::chrono::steady_clock::now() + std::chrono::seconds{5}; + while (!ready() && !ioc.stopped() && + std::chrono::steady_clock::now() < limit) + ioc.run_one_until(limit); + return ready(); + } + + std::shared_ptr + makeAttempt( + jtx::Env& env, + boost::asio::io_context& ioc, + tcp::endpoint endpoint) + { + auto& overlay = dynamic_cast(env.app().overlay()); + auto const remote = beast::IPAddressConversion::from_asio(endpoint); + auto slot = overlay.peerFinder().new_outbound_slot(remote); + if (!BEAST_EXPECT(slot != nullptr)) + return {}; + return std::make_shared( + env.app(), + ioc, + endpoint, + overlay.resourceManager().newOutboundEndpoint(remote), + context_, + 100000, + slot, + env.app().journal("Peer"), + overlay); + } + + static void + stopAttempt(std::weak_ptr const& weak) + { + if (auto attempt = weak.lock()) + attempt->stop(); + } + + void + testRefused() + { + testcase("refused connection releases its attempt"); + jtx::Env env{*this}; + boost::asio::io_context ioc; + // Reserve an endpoint without listening; no close/rebind race. + tcp::acceptor reserved(ioc); + reserved.open(tcp::v4()); + reserved.bind({boost::asio::ip::make_address("127.0.0.1"), 0}); + auto attempt = makeAttempt(env, ioc, reserved.local_endpoint()); + if (!attempt) + return; + std::weak_ptr weak = attempt; + attempt->run(); + attempt.reset(); + ioc.run_for(std::chrono::seconds{5}); + BEAST_EXPECT(weak.expired()); + BEAST_EXPECT(ioc.stopped()); + stopAttempt(weak); + ioc.restart(); + ioc.run_for(std::chrono::seconds{1}); + } + + void + testRejected(Behavior behavior) + { + testcase( + behavior == Behavior::reject ? "TLS peer refuses protocol upgrade" + : "peer closes during TLS handshake"); + jtx::Env env{*this}; + boost::asio::io_context ioc; + Listener server(ioc, *context_, behavior); + auto attempt = makeAttempt(env, ioc, server.endpoint()); + if (!attempt) + return; + std::weak_ptr weak = attempt; + attempt->run(); + attempt.reset(); + ioc.run_for(std::chrono::seconds{5}); + BEAST_EXPECT(server.accepted); + if (behavior == Behavior::reject) + { + BEAST_EXPECT(server.handshaken); + BEAST_EXPECT(server.requested); + BEAST_EXPECT(server.request["Connect-As"] == "Peer"); + BEAST_EXPECT(server.closedByClient); + } + BEAST_EXPECT(weak.expired()); + BEAST_EXPECT(ioc.stopped()); + stopAttempt(weak); + server.stop(); + ioc.restart(); + ioc.run_for(std::chrono::seconds{1}); + } + + void + testStop() + { + testcase("stop cancels a pending TLS handshake and timer"); + jtx::Env env{*this}; + boost::asio::io_context ioc; + Listener server(ioc, *context_, Behavior::stallHandshake); + auto attempt = makeAttempt(env, ioc, server.endpoint()); + if (!attempt) + return; + std::weak_ptr weak = attempt; + attempt->run(); + BEAST_EXPECT(runUntil(ioc, [&] { return server.handshakeBytes; })); + // Exercise the off-strand post and repeated stop as well as cancel. + attempt->stop(); + attempt->stop(); + attempt.reset(); + ioc.restart(); + ioc.run_for(std::chrono::seconds{5}); + BEAST_EXPECT(server.closedByClient); + BEAST_EXPECT(weak.expired()); + BEAST_EXPECT(ioc.stopped()); + server.stop(); + } + + void + testDeadlines() + { + testcase("stalled handshake and HTTP response expire"); + jtx::Env env{*this}; + boost::asio::io_context ioc; + Listener handshake(ioc, *context_, Behavior::stallHandshake); + Listener response(ioc, *context_, Behavior::stallResponse); + auto first = makeAttempt(env, ioc, handshake.endpoint()); + auto second = makeAttempt(env, ioc, response.endpoint()); + if (!first || !second) + return; + std::weak_ptr firstWeak = first; + std::weak_ptr secondWeak = second; + first->run(); + second->run(); + first.reset(); + second.reset(); + // Exercise the real 15-second timers concurrently. The upper bound + // keeps a missing wait from hanging the suite indefinitely. + ioc.run_for(std::chrono::seconds{20}); + BEAST_EXPECT(handshake.handshakeBytes); + BEAST_EXPECT(response.handshaken); + BEAST_EXPECT(response.requested); + BEAST_EXPECT(handshake.closedByClient); + BEAST_EXPECT(response.closedByClient); + BEAST_EXPECT(firstWeak.expired()); + BEAST_EXPECT(secondWeak.expired()); + BEAST_EXPECT(ioc.stopped()); + stopAttempt(firstWeak); + stopAttempt(secondWeak); + handshake.stop(); + response.stop(); + ioc.restart(); + ioc.run_for(std::chrono::seconds{1}); + } + + void + testPeer(bool graceful) + { + testcase( + graceful ? "peer handles TLS close and cancels its timer" + : "peer stop closes once and releases pending work"); + jtx::Env env{*this}; + auto& overlay = dynamic_cast(env.app().overlay()); + boost::asio::io_context ioc; + tcp::acceptor acceptor( + ioc, {boost::asio::ip::make_address("127.0.0.1"), 0}); + boost::asio::ssl::stream remote(ioc, *context_); + auto stream = std::make_unique(ioc, *context_); + stream->next_layer().socket().connect(acceptor.local_endpoint()); + acceptor.accept(remote.next_layer()); + auto const endpoint = + beast::IPAddressConversion::from_asio(acceptor.local_endpoint()); + auto const local = beast::IPAddressConversion::from_asio( + stream->next_layer().socket().local_endpoint()); + int handshakes = 0; + remote.async_handshake( + boost::asio::ssl::stream_base::server, [&](error_code ec) { + BEAST_EXPECT(!ec); + if (!ec) + ++handshakes; + }); + stream->async_handshake( + boost::asio::ssl::stream_base::client, [&](error_code ec) { + BEAST_EXPECT(!ec); + if (!ec) + ++handshakes; + }); + ioc.run_for(std::chrono::seconds{5}); + if (!BEAST_EXPECT(handshakes == 2)) + return; + + auto slot = overlay.peerFinder().new_outbound_slot(endpoint); + if (!BEAST_EXPECT(slot != nullptr)) + return; + if (!BEAST_EXPECT(overlay.peerFinder().onConnected(slot, local))) + { + overlay.peerFinder().on_closed(slot); + return; + } + PublicKey key(std::get<0>(randomKeyPair(KeyType::ed25519))); + // Reserve the fixture peer independently of the standalone app's + // automatic outbound-peer target. + if (!BEAST_EXPECT( + overlay.peerFinder().activate(slot, key, true) == + PeerFinder::Result::success)) + { + overlay.peerFinder().on_closed(slot); + return; + } + auto const disconnects = overlay.getPeerDisconnect(); + auto peer = std::make_shared( + env.app(), + std::move(stream), + boost::asio::const_buffer{}, + std::move(slot), + http_response_type{}, + overlay.resourceManager().newOutboundEndpoint(endpoint), + key, + ProtocolVersion{1, 7}, + 100000, + overlay); + std::weak_ptr weak = peer; + overlay.add_active(peer); + int shutdowns = 0; + if (graceful) + { + remote.async_shutdown([&](error_code) { ++shutdowns; }); + } + else + { + peer->stop(); + peer->stop(); + } + peer.reset(); + ioc.restart(); + ioc.run_for(std::chrono::seconds{5}); + if (graceful) + BEAST_EXPECT(shutdowns == 1); + BEAST_EXPECT(weak.expired()); + BEAST_EXPECT(overlay.size() == 0); + BEAST_EXPECT(overlay.getPeerDisconnect() == disconnects + 1); + BEAST_EXPECT(ioc.stopped()); + if (auto remaining = weak.lock()) + remaining->stop(); + error_code ec; + remote.next_layer().close(ec); + ioc.restart(); + ioc.run_for(std::chrono::seconds{1}); + } + + void + run() override + { + testRefused(); + testRejected(Behavior::abortHandshake); + testRejected(Behavior::reject); + testStop(); + testDeadlines(); + testPeer(false); + testPeer(true); + } +}; + +BEAST_DEFINE_TESTSUITE(OverlayLifecycle, overlay, ripple); + +} // namespace test +} // namespace ripple diff --git a/src/xrpld/overlay/detail/ConnectAttempt.cpp b/src/xrpld/overlay/detail/ConnectAttempt.cpp index d8c4ddff7d..61bdc99139 100644 --- a/src/xrpld/overlay/detail/ConnectAttempt.cpp +++ b/src/xrpld/overlay/detail/ConnectAttempt.cpp @@ -263,6 +263,9 @@ ConnectAttempt::onWrite(error_code ec) return; if (ec) return fail("onWrite", ec); + // The write's deadline was canceled above. Bound the upgrade response + // too, otherwise a TLS peer can keep this attempt alive without replying. + setTimer(); boost::beast::http::async_read( stream_, read_buf_,