test: socket error handling in HTTPServer using ErrorSock mock socket

Implements a child class of DynSock which is used as the mock
socket for HTTPServer unit tests. The ErrorSock::Send() method
raises a non-permanent error on the first HTTPRequest::WriteReply()
and then succeeds after the second.

In httpserver_tests.cpp use this mechanism to ensure that the
server retries a send operation if such an error is encountered,
and cover both optimistic (worker thread WriteReply()) and
non-optimistic (I/O thread SocketHandlerConnected()) send paths.
This commit is contained in:
Matthew Zipkin
2026-06-26 15:25:44 -04:00
parent 73da2a8a52
commit 922b08d375
3 changed files with 129 additions and 20 deletions

View File

@@ -5,6 +5,7 @@
#include <httpserver.h>
#include <rpc/protocol.h>
#include <test/util/common.h>
#include <test/util/logging.h>
#include <test/util/setup_common.h>
#include <util/string.h>
@@ -628,4 +629,122 @@ BOOST_AUTO_TEST_CASE(http_server_socket_tests)
server.StopListening();
}
BOOST_AUTO_TEST_CASE(http_socket_error_tests)
{
// Hard-code the server's request handler to respond to each request with
// an incremented block count.
int height{0};
HTTPServer server{[&](std::shared_ptr<HTTPRequest> req) {
req->WriteReply(HTTP_OK, strprintf("height: %d\n", height++));
}};
// All replies will be the same size
static constexpr std::size_t reply_length = std::string_view{
"HTTP/1.1 200 OK\r\n"
"Date: Thu, 01 Jan 2026 00:00:00 GMT\r\n" // All RFC1123 dates are 29 characters
"Content-Length: 10\r\n"
"Content-Type: text/html; charset=ISO-8859-1\r\n"
"\r\n"
"height: 0\n"
}.size();
/**
* A mocked Sock derived from DynSock whose Send() only succeeds when there is more than
* one reply being sent (send buffer length > reply_length). Otherwise it returns
* a recoverable error (WSAEAGAIN).
*
* After it sends successfully once, it continues to always succeed.
*
* Useful for testing "try again" logic around non-blocking socket Send() failures.
*/
class ErrorSock : public DynSock
{
public:
explicit ErrorSock(std::shared_ptr<Pipes> pipes) : DynSock{std::move(pipes)} {}
DynSock& operator=(Sock&&) override { assert(false); return *this; }
ssize_t Send(const void* buf, size_t len, int flags) const override
{
if (len <= reply_length && !m_have_sent) {
#ifdef WIN32
WSASetLastError(WSAEWOULDBLOCK);
#else
errno = WSAEAGAIN;
#endif
return -1;
} else {
m_have_sent = true;
return DynSock::Send(buf, len, flags);
}
}
mutable bool m_have_sent{false};
};
// Simpler server startup than the last test
CService addr_bind{Lookup("0.0.0.0", /*portDefault=*/0, /*fAllowLookup=*/false).value()};
BOOST_REQUIRE(server.BindAndStartListening(addr_bind));
server.StartSocketsThreads();
// Prepare initial requests
int num_requests = 3;
// Use keep-alive so the server holds the connection open for all requests.
std::string keepalive_request{full_request};
keepalive_request.replace(keepalive_request.find("Connection: close"), 17, "Connection: keep-alive");
// Combine all requests so they are read from the socket on a single iteration of the I/O loop
std::string all_requests;
for (int i = 0; i < num_requests; i++) {
all_requests += keepalive_request;
}
// Watch the log messages to ensure that the first two replies were sent
// together. This indicates the non-optimistic send path was used
// because a reply was already sitting in the send buffer when a second reply
// was added.
DebugLogHelper find_two_replies{strprintf("Sent %d bytes to client", reply_length * 2),
[&](const std::string* s) {
return true;
}};
// Last reply should be sent on its own by optimistic send path, because
// the send buffer was empty when the reply was written.
DebugLogHelper find_one_reply{strprintf("Sent %d bytes to client", reply_length),
[&](const std::string* s) {
return true;
}};
// Connect the ErrorSock as mock client with the preloaded data and get a handle on the I/O pipes
std::shared_ptr<ErrorSock::Pipes> mock_client_socket_pipes{
ConnectClient<ErrorSock>(std::as_bytes(std::span(all_requests)))
};
// Wait up to one minute for the last reply from the server
std::string actual;
char buf[0x10000] = {};
int attempts = 1000;
while (attempts > 0)
{
ssize_t bytes_read = mock_client_socket_pipes->send.GetBytes(buf, sizeof(buf), 0);
if (bytes_read > 0) {
actual.append(buf, bytes_read);
if (actual.find(strprintf("height: %d", num_requests - 1)) != std::string::npos) {
break;
}
}
std::this_thread::sleep_for(10ms);
--attempts;
}
// All replies were received
for (int i = 0; i < num_requests; i++) {
BOOST_REQUIRE(actual.find(strprintf("height: %d", i)) != std::string::npos);
}
// Close the keep-alive connection
server.DisconnectAllClients();
server.InterruptNet();
server.JoinSocketsThreads();
server.StopListening();
}
BOOST_AUTO_TEST_SUITE_END()

View File

@@ -657,24 +657,6 @@ SocketTestingSetup::~SocketTestingSetup()
CreateSock = m_create_sock_orig;
}
std::shared_ptr<DynSock::Pipes> SocketTestingSetup::ConnectClient(std::span<const std::byte> data)
{
// I/O pipes for a mock Connected Socket we can read and write to.
auto connected_socket_pipes(std::make_shared<DynSock::Pipes>());
// Insert the payload
connected_socket_pipes->recv.PushBytes(data.data(), data.size());
// Create the Mock Connected Socket that represents a client.
// It needs I/O pipes but its queue can remain empty
std::unique_ptr<DynSock> connected_socket{std::make_unique<DynSock>(connected_socket_pipes)};
// Push into the queue of Accepted Sockets returned by the local CreateSock()
m_accepted_sockets.Push(std::move(connected_socket));
return connected_socket_pipes;
}
/**
* @returns a real block (0000000000013b8ab2cd513b0261a14096412195a72a0c4827d229dcc7e0f7af)
* with 9 txs.

View File

@@ -257,10 +257,18 @@ public:
~SocketTestingSetup();
/**
* Connect to the socket with a mock client (a DynSock) and send pre-loaded data.
* Connect to the socket with a mock client and send pre-loaded data.
* Returns the I/O pipes from the mock client so we can read response data sent to it.
* Template parameter selects the socket type: DynSock by default.
*/
std::shared_ptr<DynSock::Pipes> ConnectClient(std::span<const std::byte> data);
template <typename T = DynSock>
std::shared_ptr<typename T::Pipes> ConnectClient(std::span<const std::byte> data)
{
auto connected_socket_pipes(std::make_shared<typename T::Pipes>());
connected_socket_pipes->recv.PushBytes(data.data(), data.size());
m_accepted_sockets.Push(std::make_unique<T>(connected_socket_pipes));
return connected_socket_pipes;
}
private:
//! Save the original value of CreateSock here and restore it when the test ends.