|
5 | 5 | #include <httpserver.h> |
6 | 6 | #include <rpc/protocol.h> |
7 | 7 | #include <test/util/common.h> |
| 8 | +#include <test/util/logging.h> |
8 | 9 | #include <test/util/setup_common.h> |
9 | 10 | #include <util/string.h> |
| 11 | +#include <util/threadpool.h> |
10 | 12 |
|
11 | 13 | #include <boost/test/unit_test.hpp> |
12 | 14 |
|
@@ -628,4 +630,160 @@ BOOST_AUTO_TEST_CASE(http_server_socket_tests) |
628 | 630 | server.StopListening(); |
629 | 631 | } |
630 | 632 |
|
| 633 | +BOOST_AUTO_TEST_CASE(http_socket_error_tests) |
| 634 | +{ |
| 635 | + // Create a tiny threadpool for the HTTPRequest handler |
| 636 | + ThreadPool workers("http"); |
| 637 | + workers.Start(1); |
| 638 | + |
| 639 | + // Hard-code the server's request handler to respond to each request with |
| 640 | + // an incremented block count. Handle the replies in the worker thread. |
| 641 | + std::atomic<int> height{0}; |
| 642 | + HTTPServer server{[&](std::shared_ptr<HTTPRequest> req) { |
| 643 | + auto item = [req, &height]() { |
| 644 | + const int h = height.fetch_add(1); |
| 645 | + req->WriteReply(HTTP_OK, strprintf("height: %d\n", h)); |
| 646 | + }; |
| 647 | + // Can't call BOOST_REQUIRE from worker thread |
| 648 | + Assert(workers.Submit(std::move(item))); |
| 649 | + }}; |
| 650 | + |
| 651 | + // All replies will be the same size |
| 652 | + static constexpr std::size_t reply_length = std::string_view{ |
| 653 | + "HTTP/1.1 200 OK\r\n" |
| 654 | + "Date: Thu, 01 Jan 2026 00:00:00 GMT\r\n" // All RFC1123 dates are 29 characters |
| 655 | + "Content-Length: 10\r\n" |
| 656 | + "Content-Type: text/html; charset=ISO-8859-1\r\n" |
| 657 | + "\r\n" |
| 658 | + "height: 0\n" |
| 659 | + }.size(); |
| 660 | + |
| 661 | + /** |
| 662 | + * A mocked Sock derived from DynSock whose Send() only succeeds when there is more than |
| 663 | + * one reply being sent (send buffer length > reply_length). Otherwise it returns |
| 664 | + * a recoverable error (WSAEAGAIN). |
| 665 | + * |
| 666 | + * After it sends successfully once, it continues to always succeed. |
| 667 | + * |
| 668 | + * Useful for testing "try again" logic around non-blocking socket Send() failures. |
| 669 | + */ |
| 670 | + class ErrorSock : public DynSock |
| 671 | + { |
| 672 | + public: |
| 673 | + explicit ErrorSock(std::shared_ptr<Pipes> pipes) : DynSock{std::move(pipes)} {} |
| 674 | + DynSock& operator=(Sock&&) override { assert(false); return *this; } |
| 675 | + |
| 676 | + ssize_t Send(const void* buf, size_t len, int flags) const override |
| 677 | + { |
| 678 | + if (len <= reply_length && !m_have_sent) { |
| 679 | + #ifdef WIN32 |
| 680 | + WSASetLastError(WSAEWOULDBLOCK); |
| 681 | + #else |
| 682 | + errno = WSAEAGAIN; |
| 683 | + #endif |
| 684 | + return -1; |
| 685 | + } else { |
| 686 | + m_have_sent = true; |
| 687 | + return DynSock::Send(buf, len, flags); |
| 688 | + } |
| 689 | + } |
| 690 | + |
| 691 | + mutable bool m_have_sent{false}; |
| 692 | + }; |
| 693 | + |
| 694 | + // Simpler server startup than the last test |
| 695 | + CService addr_bind{Lookup("0.0.0.0", /*portDefault=*/0, /*fAllowLookup=*/false).value()}; |
| 696 | + BOOST_REQUIRE(server.BindAndStartListening(addr_bind)); |
| 697 | + server.StartSocketsThreads(); |
| 698 | + |
| 699 | + // Prepare initial requests |
| 700 | + int num_requests = 2; |
| 701 | + // Use keep-alive so the server holds the connection open for all requests. |
| 702 | + std::string keepalive_request{full_request}; |
| 703 | + keepalive_request.replace(keepalive_request.find("Connection: close"), 17, "Connection: keep-alive"); |
| 704 | + // Combine all requests so they are read from the socket on a single iteration of the I/O loop |
| 705 | + std::string all_requests; |
| 706 | + for (int i = 0; i < num_requests; i++) { |
| 707 | + all_requests += keepalive_request; |
| 708 | + } |
| 709 | + |
| 710 | + // Watch the log messages to ensure that the first two replies were sent |
| 711 | + // together. This indicates the non-optimistic send path was used |
| 712 | + // because a reply was already sitting in the send buffer when a second reply |
| 713 | + // was added. |
| 714 | + DebugLogHelper find_two_replies{strprintf("Sent %d bytes to client", reply_length * 2), |
| 715 | + [&](const std::string* s) { |
| 716 | + return true; |
| 717 | + }}; |
| 718 | + // Last reply should be sent on its own by optimistic send path, because |
| 719 | + // the send buffer was empty when the reply was written. |
| 720 | + DebugLogHelper find_one_reply{strprintf("Sent %d bytes to client", reply_length), |
| 721 | + [&](const std::string* s) { |
| 722 | + return true; |
| 723 | + }}; |
| 724 | + |
| 725 | + // Connect the ErrorSock as mock client with the preloaded data and get a handle on the I/O pipes |
| 726 | + std::shared_ptr<ErrorSock::Pipes> mock_client_socket_pipes{ |
| 727 | + ConnectClient<ErrorSock>(std::as_bytes(std::span(all_requests))) |
| 728 | + }; |
| 729 | + |
| 730 | + // Wait up to one minute for the last reply from the server |
| 731 | + std::string actual; |
| 732 | + char buf[0x10000] = {}; |
| 733 | + int attempts = 6000; |
| 734 | + while (attempts > 0) |
| 735 | + { |
| 736 | + ssize_t bytes_read = mock_client_socket_pipes->send.GetBytes(buf, sizeof(buf), 0); |
| 737 | + if (bytes_read > 0) { |
| 738 | + actual.append(buf, bytes_read); |
| 739 | + if (actual.find(strprintf("height: %d", num_requests - 1)) != std::string::npos) { |
| 740 | + break; |
| 741 | + } |
| 742 | + } |
| 743 | + std::this_thread::sleep_for(10ms); |
| 744 | + --attempts; |
| 745 | + } |
| 746 | + |
| 747 | + // Send the third request. |
| 748 | + // If there was a race between WriteReply() in the worker thread setting m_send_ready=true |
| 749 | + // and SocketHandlerConnected() in the I/O thread flushing the send buffer, |
| 750 | + // then the socket would be stuck in write mode with nothing to write, |
| 751 | + // the server would never read from the socket, and this request would time out. |
| 752 | + // Wait a second to ensure both the worker thread and I/O thread are idle. |
| 753 | + // If we send the next request too soon it might get accepted by the server before |
| 754 | + // it gets wedged shut. |
| 755 | + std::this_thread::sleep_for(1000ms); |
| 756 | + mock_client_socket_pipes->recv.PushBytes(keepalive_request.data(), keepalive_request.size()); |
| 757 | + num_requests++; |
| 758 | + |
| 759 | + // Wait up to one minute for reply |
| 760 | + attempts = 6000; |
| 761 | + while (attempts > 0) |
| 762 | + { |
| 763 | + ssize_t bytes_read = mock_client_socket_pipes->send.GetBytes(buf, sizeof(buf), 0); |
| 764 | + if (bytes_read > 0) { |
| 765 | + actual.append(buf, bytes_read); |
| 766 | + if (actual.find(strprintf("height: %d", num_requests - 1)) != std::string::npos) { |
| 767 | + break; |
| 768 | + } |
| 769 | + } |
| 770 | + std::this_thread::sleep_for(10ms); |
| 771 | + --attempts; |
| 772 | + } |
| 773 | + |
| 774 | + // All replies were received |
| 775 | + for (int i = 0; i < num_requests; i++) { |
| 776 | + BOOST_REQUIRE(actual.find(strprintf("height: %d", i)) != std::string::npos); |
| 777 | + } |
| 778 | + |
| 779 | + // Close the keep-alive connection |
| 780 | + server.DisconnectAllClients(); |
| 781 | + |
| 782 | + workers.Stop(); |
| 783 | + |
| 784 | + server.InterruptNet(); |
| 785 | + server.JoinSocketsThreads(); |
| 786 | + server.StopListening(); |
| 787 | +} |
| 788 | + |
631 | 789 | BOOST_AUTO_TEST_SUITE_END() |
0 commit comments