http: throttle send buffer when client stops draining #36174

pull pinheadmz wants to merge 1 commits into bitcoin:master from pinheadmz:http-throttle-send changing 3 files +99 −16
  1. pinheadmz commented at 11:49 AM on September 5, 2026: member

    This is a follow-up to #36123 and applies a second throttle mechanism to the send-side. If a misbehaving client refuses to read data from the socket, the server will now stop processing requests instead of packing more and more responses to m_send_buffer without bound.

    After we parse a complete request from a client, before we dispatch it to a worker, we quickly lock and check the size of m_send_buffer. If there's already 32MiB of data there (reusing MAX_BODY_SIZE here, open for bikeshedding...) we do not dispatch the request to a worker, leaving it in place as m_req.

    This was found and disclosed responsibly by the Red Team πŸŸ₯.

  2. DrahtBot added the label RPC/REST/ZMQ on Sep 5, 2026
  3. DrahtBot commented at 11:49 AM on September 5, 2026: contributor

    <!--e57a25ab6845829454e8d69fc972939a-->

    The following sections might be updated with supplementary metadata relevant to reviewers and maintainers.

    <!--006a51241073e994b41acfe9ec718e94-->

    Code Coverage & Benchmarks

    For details see: https://corecheck.dev/bitcoin/bitcoin/pulls/36174.

    <!--021abf342d371248e50ceaed478a90ca-->

    Reviews

    See the guideline and AI policy for information on the review process.

    Type Reviewers
    ACK hodlinator, janb84, sedited
    Concept ACK winterrdog, l0rinc, jeanpablojp

    If your review is incorrectly listed, please copy-paste <code>&lt;!--meta-tag:bot-skip--&gt;</code> into the comment that the bot should ignore.

    <!--174a7506f384e20aa4161008e828411d-->

    Conflicts

    Reviewers, this pull request conflicts with the following ones:

    • #36124 (http: Make HTTPRequest update state internally by hodlinator)

    If you consider this pull request important, please also help to review the conflicting pull requests. Ideally, start with the one that should be merged first.

    <!--5faf32d7da4f0f540f40219e4f7537a3-->

    LLM Linter (✨ experimental)

    Possible places where comparison-specific test macros should replace generic comparisons:

    • [test/functional/interface_http.py] assert response_body_size > 7 * 1024 * 1024, f"Big block JSON response size is {response_body_size} bytes" -> recommend assert_greater_than(response_body_size, 7 * 1024 * 1024, ...)
    • [test/functional/interface_http.py] assert count < num_req, f"Server handled the whole batch of {num_req}: nothing was throttled" -> recommend assert_greater_than(num_req, count, ...)
    • [test/functional/interface_http.py] assert tries > 0, f"Progress failed to stall after {count} requests were handled." -> recommend assert_greater_than(tries, 0, ...)

    <sup>2026-09-09 17:16:07</sup>

  4. fanquake added this to the milestone 32.0 on Sep 5, 2026
  5. winterrdog commented at 4:02 PM on September 5, 2026: contributor

    Concept ACK

  6. l0rinc commented at 6:46 PM on September 5, 2026: contributor

    Concept ACK

  7. pinheadmz force-pushed on Sep 5, 2026
  8. pinheadmz commented at 10:07 PM on September 5, 2026: member

    push to 4e2caa0bd43c12866ab1179fe949d43d4cd61325

    Rebase on master after merge of #36123

  9. jeanpablojp commented at 1:03 AM on September 7, 2026: contributor

    Concept ACK

    The new test does not separate a correct throttle from a wrong one. Swapping MAX_BODY_SIZE for zero in the condition, the server stops after the first response and the test passes all the same, reporting "stalled after 1 requests were handled". With 1 MiB in its place it passes too. Neither of those made it fail. Deleting the return nullptr did, and then it reports 58 requests.

    The wait loop breaks as soon as two consecutive samples of the log give the same number, whatever that number is.

    Would it make sense to pin how many responses have to be queued before that counts as a stall?

  10. in test/functional/interface_http.py:832 in 4e2caa0bd4 outdated
     827 | +            while True:
     828 | +                dl.seek(dl_prev_size)
     829 | +                log = dl.read()
     830 | +                count = log.count(URI)
     831 | +                if count == prev_count:
     832 | +                    self.log.info(f"Response progress stalled after {count} requests were handled.")
    


    jeanpablojp commented at 1:03 AM on September 7, 2026:

    This comparison has no floor, so it matches a count of zero and a server that answered only once.

    The product below is not the buffer size, the log counts dispatches and the kernel drains as it goes, but a stall reached with less output than the threshold cannot be this throttle. MAX_BODY_SIZE is already at the top of the file and response_size was measured just above.

                        self.log.info(f"Response progress stalled after {count} requests were handled.")
                        assert count < num_req, f"server handled the whole batch of {num_req}: nothing was throttled"
                        assert count * response_size > MAX_BODY_SIZE, (
                            f"progress stopped after {count} responses ({count * response_size} bytes), "
                            f"below the {MAX_BODY_SIZE} byte send-buffer throttle")
    

    Green on the head with count 5 and red with the constant swapped for zero. Worth it?


    pinheadmz commented at 3:03 PM on September 8, 2026:

    You're right even though buffer sizes differ across platforms we should always be able to assert that the throttle kicked in at least after 32 MB and before all the requests were handled. I'm also going to require count > 1 so the test doesn't break at 0 == 0

  11. in src/httpserver.cpp:1009 in 4e2caa0bd4
    1005 | @@ -1006,14 +1006,17 @@ HTTPServer::IOReadiness HTTPServer::GenerateWaitSockets() const
    1006 |          Sock::Event event{0};
    1007 |          if (http_client->ReadyToSend()) {
    1008 |              event = Sock::SendEvent;
    1009 | -        } else if (http_client->GetRequest() != nullptr || http_client->ReceiveBufferEmpty()) {
    1010 | +        } else if ((http_client->GetRequest() != nullptr && http_client->GetRequest()->GetState() != HTTPRequest::State::Complete)
    


    jeanpablojp commented at 1:03 AM on September 7, 2026:

    Did you manage to reach this branch? ReadyToSend() is tested before it, and a request is only held with m_send_buffer above 32 MiB, which as far as I read already guarantees m_send_ready on any connection that is not already being dropped for a permanent send error.

    Instrumenting the loop, the held-and-complete state showed up 44 times over two runs of the whole test file, every one of them with ReadyToSend() true. Reverting the condition to the previous form leaves the file passing with the same numbers.

    Is there a live case where m_send_ready goes false with the buffer full?


    pinheadmz commented at 7:03 PM on September 8, 2026:

    This is a great catch. I built with coverage and ran a few different permutations of code and test and you're right, there's no way m_req would be complete at this point with ReadyToSend() true. I just removed the dead condition.

  12. in src/httpserver.cpp:1103 in 4e2caa0bd4
    1098 | +    // data from this client until they drain their end of the socket,
    1099 | +    // and prevent the server from packing more responses into the send buffer.
    1100 | +    const size_t buffer_used{WITH_LOCK(
    1101 | +        client->m_send_mutex,
    1102 | +        return client->m_send_buffer.size();)};
    1103 | +    if (buffer_used > MAX_BODY_SIZE) return nullptr;
    


    jeanpablojp commented at 1:03 AM on September 7, 2026:

    On the bikeshed, with numbers from both sides. The suspension threshold is per connection and -rpcmaxconnections defaults to 16, so that is 512 MiB before counting the in-flight response each connection can still put on top of it.

    With 16 connections that never read the plateau sits at 1474 MB, against 327 MB with 1 MiB in place of the constant, and with the default -rpcservertimeout they are dropped after 36 seconds.

    On the other side, the allowance is what pays for pipelining. Twelve 7.5 MB responses to a fast-reading client take 1.3 to 1.9 s here and 2.5 to 2.9 s with 1 MiB, over twenty-six runs with no overlap, and at 8 MB/s the two are level. Is 32 MiB deliberate?



    pinheadmz commented at 7:08 PM on September 8, 2026:

    It's a fairly arbitrary choice, I just needed to bound it somewhere. I think ~1GB worst case scenario under default settings is OK.

  13. in src/httpserver.h:533 in 4e2caa0bd4
     527 | @@ -527,6 +528,9 @@ class HTTPRemoteClient
     528 |       * Used to determine if an incomplete request is in progress.
     529 |       * @returns nullptr after a complete request is moved to a worker thread,
     530 |       *          but before reading any new data from m_recv_buffer.
     531 | +     * @note The returned request may also be in State::Complete when it has
     532 | +     *       been fully parsed but is being held back by TryReadRequest()'s
     533 | +     *       send-buffer throttle; check GetState() to distinguish the two.
    


    jeanpablojp commented at 1:03 AM on September 7, 2026:

    The functional test never lets the client drain, so neither the release of the held request nor this Complete state ends up covered, and nor does the order between the held one and the one queued behind it.

    The DummyClient already in httpserver_tests.cpp closes all three. It passes on the head, and without the new return nullptr it fails on the two checks that assert the request was held. Want it?

    BOOST_AUTO_TEST_CASE(http_send_buffer_throttle_tests)
    {
        // A socket that accepts no outbound bytes while m_blocked is set, standing in
        // for a client that has stopped draining its end of the connection.
        class StalledSock : public ZeroSock
        {
        public:
            ssize_t Send(const void*, size_t len, int) const override
            {
                return m_blocked ? 0 : static_cast<ssize_t>(len);
            }
            mutable bool m_blocked{true};
        };
    
        class DummyClient : public HTTPRemoteClient
        {
        public:
            explicit DummyClient(std::unique_ptr<Sock> sock)
                : HTTPRemoteClient{/*id=*/0, /*addr=*/CService(), /*socket=*/std::move(sock)} {}
    
            void receive(std::string_view s) { MutateRecvBuffer().append(s); }
        };
    
        const std::string wire1{"GET /first HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n"};
        const std::string wire2{"GET /second HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n"};
        const std::string wire3{"GET /third HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n"};
    
        auto sock{std::make_unique<StalledSock>()};
        StalledSock& stalled{*sock};
        std::shared_ptr<DummyClient> client{std::make_shared<DummyClient>(std::move(sock))};
    
        // The first request is dispatched normally.
        client->receive(wire1);
        auto first{HTTPRemoteClient::TryReadRequest(client)};
        BOOST_REQUIRE(first);
        BOOST_CHECK_EQUAL(first->GetURI(), "/first");
        BOOST_CHECK(!client->GetRequest());
    
        // Its reply is larger than the throttle and the client is not reading it,
        // so it stays in m_send_buffer.
        first->WriteReply(HTTP_OK, std::string(MAX_BODY_SIZE + 1, 'x'));
        BOOST_CHECK(client->ReadyToSend());
    
        // The next two arrive while the throttle holds. The second parses to
        // Complete and is held back instead of dispatched, and the third is left
        // untouched in the receive buffer behind it.
        client->receive(wire2);
        client->receive(wire3);
        BOOST_CHECK(!HTTPRemoteClient::TryReadRequest(client));
        BOOST_REQUIRE(client->GetRequest());
        BOOST_CHECK_EQUAL(client->GetRequest()->GetState(), HTTPRequest::State::Complete);
        BOOST_CHECK_EQUAL(client->GetRequest()->GetURI(), "/second");
        BOOST_CHECK_EQUAL(client->GetRecvBuffer(), wire3);
    
        // Draining the send buffer releases the held request, and the one queued
        // behind it follows in the order it arrived.
        stalled.m_blocked = false;
        BOOST_CHECK(client->MaybeSendBytesFromBuffer());
        BOOST_CHECK(!client->ReadyToSend());
        auto second{HTTPRemoteClient::TryReadRequest(client)};
        BOOST_REQUIRE(second);
        BOOST_CHECK_EQUAL(second->GetURI(), "/second");
        second->WriteReply(HTTP_OK, "");
        auto third{HTTPRemoteClient::TryReadRequest(client)};
        BOOST_REQUIRE(third);
        BOOST_CHECK_EQUAL(third->GetURI(), "/third");
        BOOST_CHECK_EQUAL(client->GetRecvBuffer().size(), 0);
    }
    

    pinheadmz commented at 7:26 PM on September 8, 2026:

    Due to your point above, I can remove this extra comment as well. I think request pipelining is well covered in other tests. I will drain a few responses in this new functional test, but not all 500+ that were requested ;-)

  14. in src/httpserver.cpp:1010 in 4e2caa0bd4
    1005 | @@ -1006,14 +1006,17 @@ HTTPServer::IOReadiness HTTPServer::GenerateWaitSockets() const
    1006 |          Sock::Event event{0};
    1007 |          if (http_client->ReadyToSend()) {
    1008 |              event = Sock::SendEvent;
    1009 | -        } else if (http_client->GetRequest() != nullptr || http_client->ReceiveBufferEmpty()) {
    1010 | +        } else if ((http_client->GetRequest() != nullptr && http_client->GetRequest()->GetState() != HTTPRequest::State::Complete)
    1011 | +                    || http_client->ReceiveBufferEmpty()) {
    


    hodlinator commented at 9:42 AM on September 7, 2026:

    nit: The current indentation implies the or-operator is part of the inner parenthesis above.

            } else if ((http_client->GetRequest() != nullptr && http_client->GetRequest()->GetState() != HTTPRequest::State::Complete)
                       || http_client->ReceiveBufferEmpty()) {
    

    pinheadmz commented at 5:28 PM on September 8, 2026:

    πŸ‘

  15. hodlinator commented at 11:17 AM on September 7, 2026: contributor

    Concept ACK 4e2caa0bd43c12866ab1179fe949d43d4cd61325

  16. in test/functional/interface_http.py:794 in 4e2caa0bd4
     785 | @@ -779,5 +786,56 @@ def check_pipelined_data_is_throttled(self):
     786 |          assert generated_block in response
     787 |  
     788 |  
     789 | +    def check_slow_read_throttle(self):
     790 | +        self.log.info("Check that request processing is throttled if the client is not draining the socket")
     791 | +        self.restart_node(0, extra_args=["-rest=1"])
     792 | +        # Generate a big block
     793 | +        self.wallet = MiniWallet(self.node)
     794 | +        self.generate(self.wallet, 130)
    


    hodlinator commented at 11:43 AM on September 7, 2026:

    Why generate more than 1?

            self.generate(self.wallet, 1)
    

    pinheadmz commented at 2:49 PM on September 8, 2026:

    Heh thanks, you caught me copypasting from feature_maxuploadtarget.py


    hodlinator commented at 12:34 PM on September 9, 2026:

    Hm... upon further inspection, it seems like only mine_large_block() is needed for the test to complete and the call to generate() can be removed entirely?

  17. in test/functional/interface_http.py:812 in 4e2caa0bd4
     807 | +        # least one server-side read operation (about 65kB, see HTTPRemoteClient::Receive()).
     808 | +        batch = ""
     809 | +        num_req = 0
     810 | +        while len(batch) < 0x10000:
     811 | +            batch += f"GET {URI} HTTP/1.1\r\nHost: somehost\r\n\r\n"
     812 | +            num_req += 1
    


    hodlinator commented at 12:36 PM on September 7, 2026:

    Could make this more declarative and only interpolate the string once:

            single_req = f"GET {URI} HTTP/1.1\r\nHost: somehost\r\n\r\n"
            num_req = 0x10000 // len(single_req)
            batch = single_req * num_req
    

    pinheadmz commented at 2:53 PM on September 8, 2026:

    Thanks this is much cleaner

  18. in src/httpserver.cpp:1106 in 4e2caa0bd4
    1101 | +        client->m_send_mutex,
    1102 | +        return client->m_send_buffer.size();)};
    1103 | +    if (buffer_used > MAX_BODY_SIZE) return nullptr;
    1104 | +
    1105 |      // If the request is ready, hand it to a worker.
    1106 |      if (client->m_req->GetState() == HTTPRequest::State::Complete) {
    


    janb84 commented at 2:27 PM on September 7, 2026:

    NIT; could move the if statement up. Currently, the lock is taken on every call, including for clients whose receive buffer yielded nothing to dispatch.

    The move also keeps the same behaviour but with less cognitive load. An incomplete request with a full send buffer returned null here before, and afterwards falls through to the same return nullptr at the end of the function. The check also stays ahead of the LogDebug, so a throttled request still does not log on every loop iteration. In the case of an incomplete request, it's pretty easy to follow what happens, where before you it was not as clear. (imho)

    // If the request is ready, hand it to a worker.
    if (client->m_req->GetState() == HTTPRequest::State::Complete) {
            // Unless this client's send buffer is full: in that case hold the
            // parsed request here instead of moving it to a worker. This prevents
            // the server from reading any more data from this client until they
            // drain their end of the socket, and prevents the server from packing
            // more responses into the send buffer.
        const size_t buffer_used{WITH_LOCK(
            client->m_send_mutex,
            return client->m_send_buffer.size();)};
        if (buffer_used > MAX_BODY_SIZE) return nullptr;
    

    pinheadmz commented at 6:45 PM on September 8, 2026:

    Great suggestion thanks, moving the lock inside the condition.

  19. janb84 commented at 2:28 PM on September 7, 2026: contributor

    Concept ACK 4e2caa0bd43c12866ab1179fe949d43d4cd61325

    I agree with (most) of the NITS/suggestions above. Have one suggestion myself. The direction of the PR looks good.

  20. pinheadmz force-pushed on Sep 8, 2026
  21. pinheadmz commented at 8:05 PM on September 8, 2026: member

    push to 92200000f2d252bc3e25e28bd339c28a59060e0a

    Address feedback from @janb84 @jeanpablojp and @hodlinator

    Mostly clean up, removed a dead forking condition and added a little extra test coverage.

  22. in src/httpserver.cpp:1018 in 92200000f2
    1018 | +            // request is being held back by TryReadRequest()'s send-buffer
    1019 | +            // throttle, leave event=0: the client stays in the I/O map so
    1020 | +            // TryReadRequest() runs first to consume buffered bytes before
    1021 | +            // admitting more socket data. Excess pipelined data backs up in the
    1022 | +            // kernel socket buffer, applying TCP backpressure instead of
    1023 | +            // accumulating without bound in m_recv_buffer.
    


    janb84 commented at 9:53 AM on September 9, 2026:

    NIT: should this not also be reverted to the old comment? It now describes outcomes the code no-longer produces. (I think)


    pinheadmz commented at 10:46 AM on September 9, 2026:

    The comment is correct but arguably could be moved up, or expanded to specify that ReadyToSend() actually covers the "held back" case.

    1. Data to send? -> send.
    2. No data to send?
      1. m_req is not null (held back by throttle or incomplete)? -> receive
      2. m_req is null (no request in progress)?
        1. m_recv_buffer is empty? -> receive
        2. m_recv_buffer is NOT empty? -> 0 (parse whatever is in the buffer first)

    My original concern when looking at this was that 2-i would break the read-side throttle we just added in #36123, if m_req is complete I don't want to read any more data from the socket. What @jeanpablojp pointed out is that in that case where we are already throttling the send-side, the outcome of the logic above MUST be, simply, 1.

    So whatever we can do here to make the next developer less confused is good. What do you think?


    janb84 commented at 11:34 AM on September 9, 2026:

    suggestion: It changes a bit more than the original PR so only take it if you think it will helpt to confuse the next developer less.

            // Event choice (send wins):
    Β Β Β Β Β Β Β Β //   1. ReadyToSend() -> Send
    Β Β Β Β Β Β Β Β //      Includes the throttle: a held-back request keeps m_send_ready
    Β Β Β Β Β Β Β Β //      set, so we do not Recv until send has drained.
    Β Β Β Β Β Β Β Β //   2. Else, parse in progress or recv buffer empty -> Recv
    Β Β Β Β Β Β Β Β //   3. Else (no parse in progress, leftover bytes in m_recv_buffer) -> 0
    Β Β Β Β Β Β Β Β //      Stay in the I/O map so TryReadRequest() drains the buffer first.
    Β Β Β Β Β Β Β Β //      Extra pipelined data waits in the kernel socket buffer
    Β Β Β Β Β Β Β Β //      (TCP backpressure), not in m_recv_buffer.
    Β Β Β Β Β Β Β Β //
    Β Β Β Β Β Β Β Β // Keep this as a separate critical section from the m_sock_mutex one above:
    Β Β Β Β Β Β Β Β // never hold m_sock_mutex and m_send_mutex at the same time here.
    Β Β Β Β Β Β Β Β // MaybeSendBytesFromBuffer() locks m_send_mutex then m_sock_mutex, so nesting
    Β Β Β Β Β Β Β Β // them in the opposite order here would risk a lock-order inversion deadlock.
            Sock::Event event{0};
            if (http_client->ReadyToSend()) {
                event = Sock::SendEvent;
            } else if (http_client->GetRequest() != nullptr || http_client->ReceiveBufferEmpty()) {
                // Mid-parse (need more bytes) or buffer empty.
                event = Sock::RecvEvent;
            }
    
  23. pinheadmz force-pushed on Sep 9, 2026
  24. pinheadmz commented at 11:57 AM on September 9, 2026: member

    push to e89854b417b7742f46af2af2c779931dd643e7c7

    Clean up the comment by @janb84 suggestions.

    I added a bit more description in there for my own benefit, and also re-wrote the part of the comment about lock order since now all the locking in this function is executed by helper functions outside the visible code block

  25. janb84 commented at 12:13 PM on September 9, 2026: contributor

    ACK e89854b417b7742f46af2af2c779931dd643e7c7

    This PR fixes the found issue from the Red Team πŸŸ₯. Confirmed the fix, build tested and reviewed the code. LGTM.

    Thanks for incorporating my suggestion.

  26. DrahtBot requested review from l0rinc on Sep 9, 2026
  27. DrahtBot requested review from jeanpablojp on Sep 9, 2026
  28. DrahtBot requested review from winterrdog on Sep 9, 2026
  29. DrahtBot requested review from hodlinator on Sep 9, 2026
  30. in test/functional/interface_http.py:814 in e89854b417
     809 | +        num_req = 0x10000 // len(single_req)
     810 | +        batch = single_req * num_req
     811 | +        self.log.info(f"Sending {num_req} big block JSON requests")
     812 | +
     813 | +        # Save a debug log checkpoint
     814 | +        dl_prev_size = self.node.debug_log_size(encoding="utf-8")
    


    hodlinator commented at 12:29 PM on September 9, 2026:

    nit: dl_prev_size sounds like the value will be updated later in the loop. Maybe call it dl_pre_request_size or dl_start_size?


    pinheadmz commented at 1:56 PM on September 9, 2026:

    Using dl_start_size here

  31. in test/functional/interface_http.py:804 in e89854b417
     799 | +
     800 | +        # Request the big block JSON once to check its size and establish the connection
     801 | +        URI = f"/rest/block/{big_block_hash}.json"
     802 | +        response = conn.get(URI).read()
     803 | +        response_size = len(response)
     804 | +        self.log.debug(f"Big block JSON response size is {response_size} bytes")
    


    hodlinator commented at 12:59 PM on September 9, 2026:

    nits:

    • Python's HTTPResponse.read() only returns the HTTP body.
    • response is only referenced once.
    • As can be shown by removing the mine_large_block()-call above, the test requires the response to be fairly large. Might as well assert to enforce it rather than log it for later troubleshooting.
            response_body_size = len(conn.get(URI).read())
            assert response_body_size > 7 * 1024 * 1024, \
                f"Big block JSON response was only {response_body_size} bytes"
    

    pinheadmz commented at 1:58 PM on September 9, 2026:

    ok, adding assertion here

  32. in test/functional/interface_http.py:824 in e89854b417 outdated
     819 | +
     820 | +        # Open the debug log and count how many of the batch requests were processed.
     821 | +        # Expect progress to stall after a few seconds.
     822 | +        tries = 10
     823 | +        prev_count = -1
     824 | +        with open(self.node.debug_log_path, encoding="utf-8", errors="replace") as dl:
    


    hodlinator commented at 1:05 PM on September 9, 2026:

    Encoding should be omitted here and for the debug_log_size() call above, see fae612424b3e70acd6011a4459518174463b3424.


    pinheadmz commented at 2:04 PM on September 9, 2026:

    I copied these parts of the test from assert_debug_log() in test_node.py which existed at the time of that PR, and the encoding arguments were left alone. Looks to me like there's two reasons:

    • seek() and tell() have to match encoding in text mode (not byte count) explained by a comment in test_node.py
    • the addition of errors="replace" is not the default policy (default is 'strict')

    So, leaving as is for now


    hodlinator commented at 7:27 PM on September 9, 2026:

    re https://github.com/bitcoin/bitcoin/pull/36174/changes#r3969343424: I should have read the commit message more closely. We'll have to wait until Python 3.15 to become minimum version before removing the encoding with full certainty I guess https://peps.python.org/pep-0686/#abstract.

    Regarding errors="replace", I think that should also be passed into debug_log_size () to avoid it throwing a ValueError.

  33. hodlinator commented at 1:18 PM on September 9, 2026: contributor

    Reviewed e89854b417b7742f46af2af2c779931dd643e7c7

  34. DrahtBot requested review from hodlinator on Sep 9, 2026
  35. pinheadmz force-pushed on Sep 9, 2026
  36. pinheadmz commented at 2:38 PM on September 9, 2026: member

    push to 749c1f7ce39edc2566e36c65e2263a007dd35d63

    Address nits in the test from @hodlinator

  37. pinheadmz commented at 3:07 PM on September 9, 2026: member

    https://github.com/bitcoin/bitcoin/actions/runs/34364929164/job/102511078827?pr=36174#step:11:7231

    [18](https://github.com/bitcoin/bitcoin/actions/runs/34364929164/job/102511078827?pr=36174#step:11:7219)
     test  2026-09-09T14:53:22.541160Z TestFramework (INFO): Response progress stalled after 3 requests were handled. 
     test  2026-09-09T14:53:22.541291Z TestFramework (ERROR): Unexpected exception: 
                                       Traceback (most recent call last):
                                         File "/home/runner/work/_temp/test/functional/test_framework/test_framework.py", line 145, in main
                                           self.run_test()
                                           ~~~~~~~~~~~~~^^
                                         File "/home/runner/work/_temp/build_ β‚ΏπŸ§ͺ_/test/functional/interface_http.py", line 164, in run_test
                                           self.check_slow_read_throttle()
                                           ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^
                                         File "/home/runner/work/_temp/build_ β‚ΏπŸ§ͺ_/test/functional/interface_http.py", line 843, in check_slow_read_throttle
                                           assert count * response_body_size > MAX_BODY_SIZE, (
                                                  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
                                       AssertionError: Progress stopped after 3 responses (22738449 bytes), below the 33554432 byte send-buffer throttle
    

    Ok yeah I was worried about this @jeanpablojp I am going to check if there is a code regression or just a test flake

  38. pinheadmz force-pushed on Sep 9, 2026
  39. DrahtBot added the label CI failed on Sep 9, 2026
  40. pinheadmz commented at 3:30 PM on September 9, 2026: member

    push to 85428ed7bd5c16054f4aeec7e5756bc7a3675fe1

    Fix flaky test, from this change

    Now the server has 30 seconds to stall, and we don't even check for stalls until we know at least 32 MB has been written.

  41. DrahtBot removed the label CI failed on Sep 9, 2026
  42. http: stop processing requests from a client when send buffer is full
    Prevents a memory exhaustion case where a misbehaving client
    refuses to read responses and drain the socket buffer. Instead of
    packing more data on to the server-side m_send_buffer, stop
    dispatching requests from the client to workers
    28b69e2988
  43. pinheadmz force-pushed on Sep 9, 2026
  44. pinheadmz commented at 5:16 PM on September 9, 2026: member

    push to 28b69e298884ed3f0b03023d9ad1cf37d15bcec6

    One more test tweak, setting the stall timeout to 5 seconds instead of 1 second. I noticed the test passed on master regularly, now it fails regularly.

  45. hodlinator approved
  46. hodlinator commented at 8:17 PM on September 9, 2026: contributor

    ACK 28b69e298884ed3f0b03023d9ad1cf37d15bcec6

    Punting on handing new requests to the worker thread until the other side drains the response to a reasonable size puts an upper bound on the amount of memory that aspect can take. The worst case remaining "loop-hole" would be request A filling the send-buffer with MAX_BODY_SIZE bytes exactly, letting request B to be processed, which in turn can write many multiples of MAX_BODY_SIZE if the response for B just happens to be that gigantic.

    Reverted httpserver.cpp to verify that the test fails. It runs out of tries in the loop as it should.

    Thanks for putting up with and incorporating most of my test suggestions!

  47. DrahtBot requested review from janb84 on Sep 9, 2026
  48. janb84 commented at 8:14 AM on September 10, 2026: contributor

    re ACK 28b69e298884ed3f0b03023d9ad1cf37d15bcec6

    changes since last ACK:

    • some nits from hodlman
    • some tweaks to tests
  49. sedited approved
  50. sedited commented at 12:19 PM on September 10, 2026: contributor

    ACK 28b69e298884ed3f0b03023d9ad1cf37d15bcec6

    The functional test seems a bit slow and convoluted, but could not detect any flakiness after running it many times.

  51. sedited merged this on Sep 10, 2026
  52. sedited closed this on Sep 10, 2026

  53. hodlinator commented at 12:43 PM on September 10, 2026: contributor

    The functional test seems a bit slow and convoluted, but could not detect any flakiness after running it many times.

    The slowness of the 5 second sleep stood out to me as well at first, but I started experimenting with timing the initial one request which is currently used to measure response size. Then I multiplied that by the min_count to get close to what is required to get the write buffer to stall request processing. Then I multiplied by 2 to have some leeway and account for the server being able to process some requests. In the end 5 seconds wasn't that far off and seemed like a decent choice.

  54. in src/httpserver.cpp:1112 in 28b69e2988
    1107 | +        // drain their end of the socket, and prevents the server from packing
    1108 | +        // more responses into the send buffer.
    1109 | +        const size_t buffer_used{WITH_LOCK(
    1110 | +            client->m_send_mutex,
    1111 | +            return client->m_send_buffer.size();)};
    1112 | +        if (buffer_used > MAX_BODY_SIZE) return nullptr;
    


    winterrdog commented at 3:46 PM on September 10, 2026:

    one idea i went back and forth on when looking at the send-buffer throttle: instead of a single 32 MiB cutoff for the send-buffer throttle, this uses two marks: a high-water mark (HWM) that enables throttling, and a lower-water mark (LWM) that disables it

    the motivation is to avoid flapping around the boundary. with a single cutoff, a connection sitting near 32 MiB can repeatedly cross above/below it as a few bytes drain and refill. that means we keep changing whether reads are allowed even though the connection's behaviour has not meaningfully changed. also, the fact that the send buffer is not fixed in size so it is continuously changing as the server queues more responses or the remote peer socket drains data

    with HWM/LWM, once we enter the congested state, we stay there until the buffer drains meaningfully below the LWM. that adds m_send_congested as a new piece of state on the client that can persist/changed across I/O ticks

    upsides of HWM/LWM over the current single-boundary approach:

    • no thrash for a connection parked near the boundary therefore fewer wake-ups on the I/O thread
    • makes the congestion state explicit and deterministic
    • gives us clear transitions to test: HWM turns throttling on, LWM turns it off

    upsides of the current single-boundary approach over HWM/LWM:

    • two constants instead of one, so there's another value to justify/tune
    • adds per-connection state that we now need to keep correct
    • a client that recovers quickly (crosses back under 32MiB right away) resumes immediately, instead of having to drain further down to LWM first. so, it is strictly better for a legitimate-but-bursty client's latency

    [!NOTE] those are some on the pros and cons i'd quickly think of. you'd think of more

    genuinely unsure which way nets out better here therefore i wanted to flag this as its own discussion point. curious if others think the hysteresis is worth the extra state, or if we're solving a thrash problem that is less likely to actually show up in practice

    <details> <summary>a quick rough idea of how i think it could be implemented </summary>

    diff --git a/src/httpserver.cpp b/src/httpserver.cpp
    index a29109c211..38282c475d 100644
    --- a/src/httpserver.cpp
    +++ b/src/httpserver.cpp
    @@ -632,6 +632,20 @@ void HTTPRemoteClient::Send(const HTTPResponse& res, std::span<const std::byte>
             // then only ever poll the socket for writeability, never read the client's
             // next request, and wedge the connection.
             if (!send_buffer_was_empty) m_send_ready = true;
    +
    +        // Latch congestion on once we cross the high watermark. This is only
    +        // ever set here; it's only cleared in MaybeSendBytesFromBuffer()
    +        // once the buffer has drained back below the low watermark, which
    +        // is what gives the hysteresis its effect.
    +        if (!m_send_congested && m_send_buffer.size() > SEND_BUF_HIGH_WATERMARK) {
    +            m_send_congested = true;
    +            LogDebug(
    +                BCLog::HTTP,
    +                "Client %s (id=%llu) is send-congested (%d bytes queued) throttling reads/dispatch",
    +                m_origin,
    +                m_id,
    +                m_send_buffer.size());
    +        }
         }
    
         LogDebug(
    @@ -1005,7 +1019,8 @@ HTTPServer::IOReadiness HTTPServer::GenerateWaitSockets() const
             //      full, TryReadRequest() holds a completed request back from a
             //      worker, so nothing new is read until send has drained.
             //   2. Else, m_req is incomplete and needs more data, or there is no
    -        //      m_req at all and the recv buffer is empty -> Recv
    +        //      m_req at all and the recv buffer is empty, provided the send buffer
    +        //      is not congested -> Recv
             //   3. Else (no parse in progress, leftover bytes in m_recv_buffer) -> 0
             //      Stay in the I/O map so TryReadRequest() drains the buffer first.
             //      Extra pipelined data waits in the kernel socket buffer
    @@ -1021,8 +1036,8 @@ HTTPServer::IOReadiness HTTPServer::GenerateWaitSockets() const
             Sock::Event event{0};
             if (http_client->ReadyToSend()) {
                 event = Sock::SendEvent;
    -        } else if (http_client->GetRequest() != nullptr || http_client->ReceiveBufferEmpty()) {
    -            // Mid-parse (need more bytes) or buffer empty.
    +        } else if (!http_client->IsSendCongested() && (http_client->GetRequest() != nullptr || http_client->ReceiveBufferEmpty())) {
    +            // Mid-parse (need more bytes) or buffer empty - but only when the send buffer is not congested.
                 event = Sock::RecvEvent;
             }
    
    @@ -1067,6 +1082,12 @@ std::unique_ptr<HTTPRequest> HTTPRemoteClient::TryReadRequest(const std::shared_
         // loop iteration.
         if (client->m_req_busy) return nullptr;
    
    +    // If this client's send buffer is congested, don't read or parse
    +    // anything new from it either. This is what stops the server from
    +    // packing more responses into m_send_buffer for a client that has
    +    // stopped draining its socket
    +    if (client->IsSendCongested()) return nullptr;
    +
         if (!client->m_req) {
             client->m_req = std::make_unique<HTTPRequest>(client);
         }
    @@ -1106,10 +1127,10 @@ std::unique_ptr<HTTPRequest> HTTPRemoteClient::TryReadRequest(const std::shared_
             // the server from reading any more data from this client until they
             // drain their end of the socket, and prevents the server from packing
             // more responses into the send buffer.
    -        const size_t buffer_used{WITH_LOCK(
    -            client->m_send_mutex,
    -            return client->m_send_buffer.size();)};
    -        if (buffer_used > MAX_BODY_SIZE) return nullptr;
    +        // const size_t buffer_used{WITH_LOCK(
    +        // client->m_send_mutex,
    +        // return client->m_send_buffer.size();)};
    +        // if (buffer_used > MAX_BODY_SIZE) return nullptr;
             LogDebug(
                 BCLog::HTTP,
                 "Received a %s request for %s from %s (id=%llu)",
    @@ -1293,6 +1314,16 @@ bool HTTPRemoteClient::MaybeSendBytesFromBuffer()
             m_send_buffer.erase(m_send_buffer.begin(),
                                 m_send_buffer.begin() + bytes_sent);
    
    +        // Only clear congestion once drained below the low watermark
    +        if (m_send_congested && m_send_buffer.size() < SEND_BUF_LOW_WATERMARK) {
    +            m_send_congested = false;
    +            LogDebug(
    +                BCLog::HTTP,
    +                "Client %s (id=%llu) send buffer drained, resuming reads/dispatch",
    +                m_origin,
    +                m_id);
    +        }
    +
             LogDebug(
                 BCLog::HTTP,
                 "Sent %d bytes to client %s (id=%llu)",
    diff --git a/src/httpserver.h b/src/httpserver.h
    index dae59a9443..f043dbe655 100644
    --- a/src/httpserver.h
    +++ b/src/httpserver.h
    @@ -78,7 +78,6 @@ inline constexpr size_t MIN_REQUEST_LINE_LENGTH = std::string_view("GET / HTTP/1
     inline constexpr size_t MAX_HEADERS_SIZE{8192};
    
     //! Maximum size of an HTTP request body received from a client.
    -//! Also used to limit data queued for sending back to client.
     inline constexpr uint64_t MAX_BODY_SIZE{32_MiB};
    
     //! Thrown when a request body exceeds MAX_BODY_SIZE (or *will* exceed, in chunked transfer)
    @@ -86,6 +85,17 @@ inline constexpr uint64_t MAX_BODY_SIZE{32_MiB};
     struct ContentTooLargeError : std::runtime_error {
         using std::runtime_error::runtime_error;
     };
    +
    +//! Send-buffer backpressure thresholds, deliberately kept separate from
    +//! MAX_BODY_SIZE: that constant bounds request bodies we accept, this pair
    +//! bounds queued response bytes we're willing to hold for a slow/stalled
    +//! reader. Once a client's queued send data exceeds the high watermark it is
    +//! marked congested (see HTTPRemoteClient::IsSendCongested()); it stays
    +//! congested until the buffer drains back below the low watermark, so a
    +//! connection sitting near the boundary does not flap in and out of
    +//! throttling on every I/O loop tick.
    +inline constexpr size_t SEND_BUF_LOW_WATERMARK{16_MiB};
    +inline constexpr size_t SEND_BUF_HIGH_WATERMARK{32_MiB};
     } // namespace bitcoin_http
    
     class HTTPHeaders
    @@ -505,6 +515,17 @@ public:
         bool ReadyToSend() const EXCLUSIVE_LOCKS_REQUIRED(!m_send_mutex) { return WITH_LOCK(m_send_mutex, return m_send_ready;); }
         bool ReceiveBufferEmpty() const { return m_recv_buffer.empty(); }
    
    +    /**
    +     * True once queued response bytes for this client have crossed
    +     * SEND_BUFFER_HIGH_WATERMARK, and stays true until they drain below
    +     * SEND_BUFFER_LOW_WATERMARK (see the constants' doc comment). Checked
    +     * identically by HTTPServer::GenerateWaitSockets() (to stop reading more
    +     * request data from the connection's socket) and TryReadRequest() (to stop
    +     * dispatching this client's requests to a worker), so both consult one
    +     * source of truth.
    +     */
    +    bool IsSendCongested() const EXCLUSIVE_LOCKS_REQUIRED(!m_send_mutex) { return WITH_LOCK(m_send_mutex, return m_send_congested;); }
    +
         void Send(const HTTPResponse& res, std::span<const std::byte> reply_body, bool keep_alive) EXCLUSIVE_LOCKS_REQUIRED(!m_send_mutex, !m_sock_mutex);
         void Receive() EXCLUSIVE_LOCKS_REQUIRED(!m_sock_mutex);
    
    @@ -514,7 +535,11 @@ public:
          * Try to read an HTTPRequest from a client's receive buffer.
          * Only complete requests are returned, incomplete requests are
          * left in the buffer to wait for more data. Some read errors
    -     * will mark this client for disconnection.
    +     * will mark this client for disconnection. Also returns nullptr,
    +     * without reading or parsing anything, while the client is
    +     * send-congested -- this is what stops a misbehaving client that
    +     * is not draining its socket from causing m_send_buffer to grow
    +     * without bound.
          */
         static std::unique_ptr<HTTPRequest> TryReadRequest(const std::shared_ptr<HTTPRemoteClient>& client) EXCLUSIVE_LOCKS_REQUIRED(!client->m_send_mutex);
    
    @@ -580,6 +605,12 @@ private:
         /// @{
         mutable Mutex m_send_mutex;
         std::vector<std::byte> m_send_buffer GUARDED_BY(m_send_mutex);
    +
    +    //! Hysteretic congestion flag;. Only ever set in
    +    //! Send() (where m_send_buffer grows) and only ever cleared in
    +    //! MaybeSendBytesFromBuffer() (where it shrinks), so it always reflects
    +    //! the buffer's size as of the last time it changed.
    +    bool m_send_congested GUARDED_BY(m_send_mutex){false};
         /// @}
    
         /**
    

    </details>


    any thoughts ?

  55. winterrdog commented at 5:00 PM on September 10, 2026: contributor

    post-merge tACK 28b69e298884ed3f0b03023d9ad1cf37d15bcec6

    left some thoughts, just below


github-metadata-mirror

This is a metadata mirror of the GitHub repository bitcoin/bitcoin. This site is not affiliated with GitHub. Content is generated from a GitHub metadata backup.
generated: 2026-10-01 19:51 UTC

This site is hosted by @0xB10C
More mirrored repositories can be found on mirror.b10c.me