proxy: add local connection limit to ListenConnections #269

pull enirox001 wants to merge 3 commits into bitcoin-core:master from enirox001:04-26-ipc-local-connection-limit changing 6 files +366 −19
  1. enirox001 commented at 10:00 AM on April 8, 2026: contributor

    This adds an optional local connection limit toListenConnections().

    Previously, ListenConnections() would accept incoming connections indefinitely. This branch adds an optional max_connections parameter so a listener can stop accepting new connections once a per-listener cap is reached, and resume accepting when an existing connection disconnects.

    The limit is local to the listener instead of global to the EventLoop. This keeps the state and behavior scoped to the listening socket, and is closer to the direction discussed downstream for per--ipcbind limits.

    This also adds a test covering the behavior with max_connections=1, verifying that:

    • the first client is accepted normally
    • a second client is not accepted while the first remains connected
    • the second client is accepted after the first disconnects

    Note This PR includes a major version bump to v12 due to the API addition. If #274 lands earlier and bumps the version to v12 first, we will need to bump the version here again.

  2. DrahtBot commented at 10:01 AM on April 8, 2026: none

    <!--e57a25ab6845829454e8d69fc972939a-->

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

    <!--021abf342d371248e50ceaed478a90ca-->

    Reviews

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

    Type Reviewers
    ACK ryanofsky
    Stale ACK xyzconstant, Eunovo

    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

    No conflicts as of last run.

    <!--5faf32d7da4f0f540f40219e4f7537a3-->

  3. enirox001 marked this as a draft on Apr 8, 2026
  4. enirox001 force-pushed on Apr 8, 2026
  5. enirox001 force-pushed on Apr 8, 2026
  6. enirox001 force-pushed on Apr 8, 2026
  7. in include/mp/proxy-io.h:829 in 84ed607b39
     821 | @@ -820,8 +822,19 @@ std::unique_ptr<ProxyClient<InitInterface>> ConnectStream(EventLoop& loop, int f
     822 |  //! handles requests from the stream by calling the init object. Embed the
     823 |  //! ProxyServer in a Connection object that is stored and erased if
     824 |  //! disconnected. This should be called from the event loop thread.
     825 | +template <typename InitInterface, typename InitImpl, typename OnDisconnect>
     826 | +void _Serve(EventLoop& loop, kj::Own<kj::AsyncIoStream>&& stream, InitImpl& init, OnDisconnect&& on_disconnect);
     827 | +
     828 |  template <typename InitInterface, typename InitImpl>
     829 |  void _Serve(EventLoop& loop, kj::Own<kj::AsyncIoStream>&& stream, InitImpl& init)
    


    ryanofsky commented at 2:02 PM on April 8, 2026:

    In commit "proxy: add local connection limit to ListenConnections" (84ed607b390e24dd0d41cd95d0159bfeadfaf5d8)

    Since _Serve is an internal function only called in a few places, I think it would be less confusing if it was not overloaded, and just always required an on_disconnect parameter, even if it requires a little extra verbosity at some call sites.


    enirox001 commented at 9:58 AM on April 9, 2026:

    Done. Simplified this now so _Serve always takes an on_disconnect callback instead of overloading it. Since it is only used internally

  8. in include/mp/proxy-io.h:865 in 84ed607b39 outdated
     861 | +        : listener(kj::mv(listener_)), max_connections(max_connections_) {}
     862 | +
     863 | +    kj::Own<kj::ConnectionReceiver> listener;
     864 | +    std::optional<size_t> max_connections;
     865 | +    size_t active_connections{0};
     866 | +    bool accept_pending{false};
    


    ryanofsky commented at 2:08 PM on April 8, 2026:

    In commit "proxy: add local connection limit to ListenConnections" (84ed607b390e24dd0d41cd95d0159bfeadfaf5d8)

    Curious if this accept_pending variable is actually necessary or if code could just compare active_connections and max_connections when deciding whether to listen. Would prefer to avoid redundancy in the state representation if possible even if makes individual checks more a little more verbose.

    If accept_pending really is necessary would be a good to have a short comment about why.


    enirox001 commented at 9:59 AM on April 9, 2026:

    I kept accept_pending, but added a short comment explaining why it is needed here. active_connections only counts accepted connections, so without a separate flag nested _Listen() calls could post multiple pending accept() calls before active_connections is incremented.


    xyzconstant commented at 7:47 PM on May 20, 2026:

    FWIW I tested the code without accept_pending field and hence its check at _Listen removed, the tests passed with no issues. Then, asked Claude to generate a test that exercises it and produced this:

    diff --git a/test/mp/test/listen_tests.cpp b/test/mp/test/listen_tests.cpp
    index b367938..78298c7 100644
    --- a/test/mp/test/listen_tests.cpp
    +++ b/test/mp/test/listen_tests.cpp
    @@ -24,8 +24,10 @@
     #include <string>
     #include <sys/socket.h>
     #include <sys/un.h>
    +#include <kj/exception.h>
     #include <thread>
     #include <unistd.h>
    +#include <vector>
     
     namespace mp {
     namespace test {
    @@ -112,11 +114,39 @@ public:
         std::thread thread;
     };
     
    +//! kj::ExceptionCallback that captures KJ_LOG output into an external sink.
    +//! Must be instantiated on the thread whose KJ logs you want to capture; it
    +//! installs itself onto that thread's ExceptionCallback stack via its base
    +//! constructor and removes itself in the destructor.
    +class CaptureLogCallback : public kj::ExceptionCallback
    +{
    +public:
    +    CaptureLogCallback(std::mutex& mu, std::string& sink) : m_mu(mu), m_sink(sink) {}
    +
    +    void logMessage(kj::LogSeverity severity, const char* file, int line, int contextDepth,
    +                    kj::String&& text) override
    +    {
    +        {
    +            std::lock_guard<std::mutex> lock(m_mu);
    +            m_sink.append(text.cStr(), text.size());
    +            m_sink.push_back('\n');
    +        }
    +        // Still let the default callback emit to stderr so test debug output
    +        // isn't silenced for other observers.
    +        kj::ExceptionCallback::logMessage(severity, file, line, contextDepth, kj::mv(text));
    +    }
    +
    +private:
    +    std::mutex& m_mu;
    +    std::string& m_sink;
    +};
    +
     class ListenSetup
     {
     public:
         explicit ListenSetup(std::optional<size_t> max_connections = std::nullopt)
             : capped_listener(max_connections.has_value()), thread([this, max_connections] {
    +              CaptureLogCallback log_capture(captured_log_mutex, captured_log);
                   EventLoop loop("mptest-server", [this](mp::LogMessage log) {
                       if (log.level == mp::Log::Raise) throw std::runtime_error(log.message);
                       if (log.message.find("IPC server: socket connected.") != std::string::npos) {
    @@ -144,6 +174,18 @@ public:
     
         ~ListenSetup()
         {
    +        forceShutdown();
    +        thread.join();
    +    }
    +
    +    //! Synchronously tear down the event loop's task set so any pending accept
    +    //! promises are destroyed now (rather than when the destructor runs later).
    +    //! This makes it possible to assert on captured KJ log output before the
    +    //! ListenSetup goes out of scope. Idempotent.
    +    void forceShutdown()
    +    {
    +        if (shutdown_done) return;
    +        shutdown_done = true;
             if (capped_listener) {
                 EventLoop* loop;
                 {
    @@ -152,7 +194,6 @@ public:
                 }
                 if (loop) loop->sync([&] { loop->m_task_set.reset(); });
             }
    -        thread.join();
         }
     
         size_t ConnectedCount()
    @@ -184,11 +225,15 @@ public:
         UnixListener listener;
         std::promise<void> ready_promise;
         bool capped_listener{false};
    +    bool shutdown_done{false};
         std::mutex counter_mutex;
         std::condition_variable counter_cv;
         EventLoop* event_loop{nullptr};
         size_t connected_count{0};
         size_t disconnected_count{0};
    +    //! KJ log output captured from the server thread via CaptureLogCallback.
    +    std::mutex captured_log_mutex;
    +    std::string captured_log;
         std::thread thread;
     };
     
    @@ -245,6 +290,34 @@ KJ_TEST("ListenConnections keeps capped listeners alive before reaching the limi
         KJ_EXPECT(client2->client->add(2, 3) == 5);
     }
     
    +// Without `accept_pending`, cascaded close handlers each post a duplicate
    +// accept(). KJ silently serializes them so the cap isn't exceeded, but the
    +// extra pending promises are destroyed at cleanup and logged as
    +// "PromiseFulfiller was destroyed without fulfilling the promise."
    +// This test fails when accept_pending is removed and passes when it's intact.
    +KJ_TEST("ListenConnections does not leak accept promises during disconnect burst")
    +{
    +    constexpr size_t kCap = 2;
    +    ListenSetup setup(/*max_connections=*/kCap);
    +
    +    std::vector<std::unique_ptr<ClientSetup>> filling;
    +    filling.reserve(kCap);
    +    for (size_t i = 0; i < kCap; ++i) {
    +        filling.push_back(std::make_unique<ClientSetup>(setup.listener.Connect()));
    +    }
    +    setup.WaitForConnectedCount(kCap);
    +
    +    filling.clear();
    +    setup.WaitForDisconnectedCount(kCap);
    +
    +    // Trigger m_task_set.reset() now so any leaked accept promises get destroyed
    +    // before we read captured_log.
    +    setup.forceShutdown();
    +
    +    std::lock_guard<std::mutex> lock(setup.captured_log_mutex);
    +    KJ_EXPECT(setup.captured_log.find("PromiseFulfiller was destroyed") == std::string::npos);
    +}
    +
     } // namespace
     } // namespace test
     } // namespace mp
    

    Basically what this does is install a kj::ExceptionCallback in the server thread to capture the log "PromiseFulfiller was destroyed" generated by KJ runtime if cascaded disconnects each call _Listen and post a fresh accept() promise. The test fails with accept_pending removed and pass with it back.


    xyzconstant commented at 7:50 PM on May 20, 2026:

    IMO it's not so obvious why this field is needed here, this patch basically guarantee the same outcome and also pass the test generated by Claude above:

    diff --git a/include/mp/proxy-io.h b/include/mp/proxy-io.h
    index 78924c6..c002811 100644
    --- a/include/mp/proxy-io.h
    +++ b/include/mp/proxy-io.h
    @@ -851,11 +851,6 @@ struct ListenState
         kj::Own<kj::ConnectionReceiver> listener;
         std::optional<size_t> max_connections;
         size_t active_connections{0};
    -    //! Tracks whether accept() has already been posted. This is needed because
    -    //! active_connections only counts accepted connections, so without a
    -    //! separate flag, nested _Listen() calls could queue multiple pending
    -    //! accepts before active_connections increases.
    -    bool accept_pending{false};
     };
     
     template <typename InitInterface, typename InitImpl>
    @@ -866,9 +861,12 @@ void _ServeAccepted(EventLoop& loop, InitImpl& init, const std::shared_ptr<Liste
     {
         ++state->active_connections;
         _Serve<InitInterface>(loop, kj::mv(stream), init, [&loop, &init, state] {
    +        const bool was_at_cap = state->max_connections && state->active_connections == *state->max_connections;
             assert(state->active_connections > 0);
             --state->active_connections;
    -        _Listen<InitInterface>(loop, init, state);
    +        if (was_at_cap) {
    +            _Listen<InitInterface>(loop, init, state);
    +        }
         });
     }
     
    @@ -885,15 +883,12 @@ inline std::unique_ptr<EventLoopRef> _MakeCappedListenerRef(EventLoop& loop, con
     template <typename InitInterface, typename InitImpl>
     void _Listen(EventLoop& loop, InitImpl& init, const std::shared_ptr<ListenState>& state)
     {
    -    if (state->accept_pending) return;
         if (_ListenAtCapacity(*state)) return;
     
    -    state->accept_pending = true;
         auto* ptr = state->listener.get();
         auto accept_ref{_MakeCappedListenerRef(loop, *state)};
         loop.m_task_set->add(ptr->accept().then(
             [&loop, &init, state, accept_ref = std::move(accept_ref)](kj::Own<kj::AsyncIoStream>&& stream) mutable {
    -            state->accept_pending = false;
                 _ServeAccepted<InitInterface>(loop, init, state, kj::mv(stream));
                 _Listen<InitInterface>(loop, init, state);
             }));
    

    enirox001 commented at 11:39 AM on May 21, 2026:

    I think this is a better invariant than tracking accept_pending.

    There should already be one pending accept whenever the capped listener is below capacity, and disconnects only need to post a new accept when they transition the listener from full to below full, because that is the only state where nocaccept was pending.

    So checking this before decrementing active_connections avoids the duplicate-accept case without adding another state variable.

    Taken and decided to use resume_accept for the local boolean instead

  9. in test/mp/test/test.cpp:51 in 84ed607b39
      46 |  namespace mp {
      47 |  namespace test {
      48 |  
      49 | +namespace {
      50 | +
      51 | +class UnixListener
    


    ryanofsky commented at 2:43 PM on April 8, 2026:

    In commit "proxy: add local connection limit to ListenConnections" (84ed607b390e24dd0d41cd95d0159bfeadfaf5d8)

    Would be good to introduce the test in a separate commit and maybe separate file if it doesn't share much code with existing test.

    IMO would be nice if it test was introduced in an intial commit then updated after connection limits are added so it's easier to see how connection limits are tested separately from connection setup.


    enirox001 commented at 10:02 AM on April 9, 2026:

    Split the listener coverage out into a dedicated listen_tests.cpp file and reordered the history so the baseline ListenConnections() test is introduced first, then extended in follow-up commit with the local connection limit coverage.

  10. ryanofsky commented at 2:44 PM on April 8, 2026: collaborator

    Approach ACK 84ed607b390e24dd0d41cd95d0159bfeadfaf5d8. Implementation of local connection limit here looks almost exactly like I would have expected.

    I do think it would be helpful to see a draft PR in the bitcoin repo using this API (it should be fine to make libmultiprocess changes there and let the lint CI job fail so you don't need to mess with subtrees) because the approach in https://github.com/bitcoin/bitcoin/pull/34978 of adding a global connection limit option isn't exactly compatible with the implementation here of implementing a per-address connection limit.

    I'd personally prefer using per-address limits over introducing a global limit but both approaches seem reasonable

  11. enirox001 force-pushed on Apr 9, 2026
  12. enirox001 force-pushed on Apr 9, 2026
  13. enirox001 force-pushed on Apr 9, 2026
  14. enirox001 force-pushed on Apr 9, 2026
  15. enirox001 commented at 10:29 AM on April 9, 2026: contributor

    Thanks for the review @ryanofsky .

    I addressed the cleanup points in the latest push:

    • _Serve is now explicit at all call sites
    • accept_pending is documented,
    • listener coverage now lives in a dedicated listen_tests.cpp file introduced in a separate commit before the local-limit extension.

    I’m also planning to put together a draft Bitcoin Core PR using this API so the per-address approach can be evaluated downstream against the current global-limit direction.

  16. enirox001 commented at 1:48 PM on April 9, 2026: contributor

    I put together the downstream draft using this API here bitcoin/bitcoin#35037

    It uses per--ipcbind max-connections=<n> options, threads the parsed per-address limit into ListenConnections(), and adds downstream coverage for the local-limit behavior.

  17. enirox001 marked this as ready for review on May 4, 2026
  18. enirox001 commented at 9:43 AM on May 4, 2026: contributor

    This PR is now ready for review

  19. in test/mp/test/listen_tests.cpp:133 in 8511c68f8b outdated
     128 | +                      ++disconnected_count;
     129 | +                      counter_cv.notify_all();
     130 | +                  }
     131 | +              });
     132 | +              FooImplementation foo;
     133 | +              ListenConnections<messages::FooInterface>(loop, listener.release(), foo, max_connections);
    


    xyzconstant commented at 2:58 AM on May 13, 2026:

    nit: This will fail to compile mptest at commit 8c47a3aba8baf145d7ef9d0efe3a1a0f67be2331 since the 4th parameter max_connections is not introduced in ListenConnections's signature until next commit. I'd fix the call for this commit and then adjust the setup accordingly in 8511c68.


    enirox001 commented at 8:41 AM on May 14, 2026:

    Fixed, thanks

  20. in test/mp/test/listen_tests.cpp:190 in 8511c68f8b outdated
     185 | +
     186 | +    setup.WaitForConnectedCount(1);
     187 | +    KJ_EXPECT(client->client->add(1, 2) == 3);
     188 | +}
     189 | +
     190 | +KJ_TEST("ListenConnections enforces a local connection limit")
    


    xyzconstant commented at 3:06 AM on May 13, 2026:

    nit: Unlike in ListenConnections's call issue (compilation error), this will still fail for this commit (8c47a3aba8baf145d7ef9d0efe3a1a0f67be2331). Following ryanosfky's reasoning (https://github.com/bitcoin-core/libmultiprocess/pull/269#discussion_r3052126251) I believe this suite would be better introduced in 8511c68f8b6827f4e9b306e0245929965a363930 so the test lands with the feature it exercises.


    enirox001 commented at 8:42 AM on May 14, 2026:

    Done

  21. xyzconstant commented at 3:15 AM on May 13, 2026: contributor

    Code review ACK 8511c68f8b6827f4e9b306e0245929965a363930

    Reviewed each commit separately, compiled and ran tests. The changes look good to me, only left a couple of inline nits noting a compilation error + failing tests in the first commit.

  22. DrahtBot requested review from ryanofsky on May 13, 2026
  23. enirox001 force-pushed on May 14, 2026
  24. enirox001 commented at 8:43 AM on May 14, 2026: contributor

    Thanks for the review @xyzconstant . Fixed both commit-structure issues: the first test commit now only adds baseline ListenConnections() coverage using the existing 3-argument API, and the max_connections setup/test now lands with the feature commit that introduces the new parameter

  25. enirox001 commented at 8:44 AM on May 14, 2026: contributor

    Also tightened the capped listener behavior. It now stops posting accepts once the limit is reached, and keeps capped pending accepts alive so later clients can connect after an idle gap, including before the cap has been reached.

    Added coverage for the reconnect cases as well

  26. enirox001 force-pushed on May 14, 2026
  27. in include/mp/proxy-io.h:898 in 19e1386bca
     899 | -            _Serve<InitInterface>(loop, kj::mv(stream), init);
     900 | -            _Listen<InitInterface>(loop, kj::mv(listener), init);
     901 | +        [&loop, &init, state, accept_ref = std::move(accept_ref)](kj::Own<kj::AsyncIoStream>&& stream) mutable {
     902 | +            state->accept_pending = false;
     903 | +            _ServeAccepted<InitInterface>(loop, init, state, kj::mv(stream));
     904 | +            if (_ListenAtCapacity(*state)) return;
    


    xyzconstant commented at 3:13 AM on May 19, 2026:

    In commit "proxy: add local connection limit to ListenConnections" (19e1386bca3cee7e2d5cc775b74eac064d522fed)

    Not sure if I'm missing something but this check here seems redundant with the same _ListenAtCapacity check at the start of _Listen (line 889).

    I tested commenting this line out and left the upper-level check (and vice-versa) and the tests passed with no issues. I'd suggest dropping any of these duplicates.


    enirox001 commented at 9:06 AM on May 19, 2026:

    Good catch, thanks. Dropped the lower _ListenAtCapacity() check since _Listen() already handles the capacity check before posting another accept.

  28. enirox001 force-pushed on May 19, 2026
  29. in include/mp/proxy-io.h:898 in b0207dd406 outdated
     899 | -            _Serve<InitInterface>(loop, kj::mv(stream), init);
     900 | -            _Listen<InitInterface>(loop, kj::mv(listener), init);
     901 | +        [&loop, &init, state, accept_ref = std::move(accept_ref)](kj::Own<kj::AsyncIoStream>&& stream) mutable {
     902 | +            state->accept_pending = false;
     903 | +            _ServeAccepted<InitInterface>(loop, init, state, kj::mv(stream));
     904 | +            _Listen<InitInterface>(loop, init, state);
    


    xyzconstant commented at 7:56 PM on May 20, 2026:

    With the accept_pending check in place, this _Listen call will never be reached.

    NOTE: if you apply this patch here (comment), then it will be needed here because we can't rely on the second _Listen call in _ServeAccepted which is executed conditionally after active_connections reached the cap.

  30. xyzconstant commented at 8:13 PM on May 20, 2026: contributor

    Thanks for the update @enirox001!

    I've been playing around with the PR code more throughly this time and have a different take on accept_pending now, so left a few comments.

    Overall the code is factually correct and works as expected. And I think it could be merged (despite my latest thoughts on accept_pending). So I'll just re-ACK b0207dd406b206e0cf2835f10e36dd0ad5a34c96

  31. enirox001 force-pushed on May 21, 2026
  32. enirox001 force-pushed on May 21, 2026
  33. enirox001 commented at 11:43 AM on May 21, 2026: contributor

    I've been playing around with the PR code more throughly this time and have a different take on accept_pending now, so left a few comments.

    Thanks for taking another look @xyzconstant

    I dropped accept_pending and switched to the simpler invariant you suggested.

  34. xyzconstant commented at 4:20 PM on May 21, 2026: contributor

    re-ACK 7e51ab235330d1a198b910871fac09a23ffdd5f4

    Thanks for the updates @enirox001!

  35. in test/mp/test/listen_tests.cpp:93 in d0cff019a9 outdated
      88 | +class ClientSetup
      89 | +{
      90 | +public:
      91 | +    explicit ClientSetup(int fd)
      92 | +        : thread([this, fd] {
      93 | +              EventLoop loop("mptest-client", [](mp::LogMessage log) {
    


    ryanofsky commented at 3:51 PM on June 2, 2026:

    In commit "test: add dedicated ListenConnections coverage" (d0cff019a9b543b0f1fb7db86fad465aa0cc9d2e)

    Here and below, it might be good to add KJ_LOG(INFO, log.level, log.message); to show debug logs when --verbose is used, like the other test file.

    I think it could also be good to add a more generic test setup class that can be reused in tests and deduplicate the EventLoop thread/join/promise code that is now repeated in 3 classes. It could have std::function hooks for running code before loop.loop(), or when messages are logged. But this is more of an idea for a followup, current code seems fine.


    enirox001 commented at 3:42 PM on June 3, 2026:

    Done. Added KJ_LOG(INFO, log.level, log.message); to the logging callbacks of EventLoop in both ClientSetup and ListenSetup to ensure debug logs print correctly under --verbose .

    Regarding the generic test setup helper, that is a good suggestion. I agree it would be a clean follow-up improvement.

  36. in test/mp/test/listen_tests.cpp:121 in d0cff019a9
     116 | +public:
     117 | +    ListenSetup()
     118 | +        : thread([this] {
     119 | +              EventLoop loop("mptest-server", [this](mp::LogMessage log) {
     120 | +                  if (log.level == mp::Log::Raise) throw std::runtime_error(log.message);
     121 | +                  if (log.message.find("IPC server: socket connected.") != std::string::npos) {
    


    ryanofsky commented at 3:57 PM on June 2, 2026:

    In commit "test: add dedicated ListenConnections coverage" (d0cff019a9b543b0f1fb7db86fad465aa0cc9d2e)

    Maybe it would be good to add a testing hook for this (grep for testing_hook_ for examples), instead of checking for a log message.

    Checking for a log message does seem ok though, and may be even better if the goal is to catch regressions since it doesn't require changing test and non-test code at the same time.


    enirox001 commented at 3:42 PM on June 3, 2026:

    Done. I added a testing_hook_connected member to EventLoop and invoked it inside _Serve .

    ListenSetup now hooks into this callback to count connections, which makes the tests more robust and consistent with the other testing_hook hooks in the codebase.

  37. in test/mp/test/listen_tests.cpp:111 in d0cff019a9 outdated
     106 | +        thread.join();
     107 | +    }
     108 | +
     109 | +    std::promise<std::unique_ptr<ProxyClient<messages::FooInterface>>> client_promise;
     110 | +    std::unique_ptr<ProxyClient<messages::FooInterface>> client;
     111 | +    std::thread thread;
    


    ryanofsky commented at 3:59 PM on June 2, 2026:

    In commit "test: add dedicated ListenConnections coverage" (d0cff019a9b543b0f1fb7db86fad465aa0cc9d2e)

    Other test class has a comment that may be useful here and below as well

        //! Thread variable should be after other struct members so the thread does
        //! not start until the other members are initialized.
    

    enirox001 commented at 3:42 PM on June 3, 2026:

    Thanks, added this comment where appropriate.

  38. in test/mp/test/test.cpp:8 in d0cff019a9
       4 | @@ -5,14 +5,15 @@
       5 |  #include <mp/test/foo.capnp.h>
       6 |  #include <mp/test/foo.capnp.proxy.h>
       7 |  
       8 | +#include <string.h> // NOLINT(modernize-deprecated-headers)
    


    ryanofsky commented at 4:02 PM on June 2, 2026:

    In commit "test: add dedicated ListenConnections coverage" (d0cff019a9b543b0f1fb7db86fad465aa0cc9d2e)

    Unclear what reason is for this change. Probably should be a separate commit if it's necessary


    enirox001 commented at 3:42 PM on June 3, 2026:

    Thanks, removed this header.

  39. in include/mp/version.h:27 in 7e51ab2353
      23 | @@ -24,7 +24,7 @@
      24 |  //! pointing at the prior merge commit. The /doc/versions.md file should also be
      25 |  //! updated, noting any significant or incompatible changes made since the
      26 |  //! previous version.
      27 | -#define MP_MAJOR_VERSION 10
      28 | +#define MP_MAJOR_VERSION 11
    


    ryanofsky commented at 4:16 PM on June 2, 2026:

    In commit "test: add dedicated ListenConnections coverage" (d0cff019a9b543b0f1fb7db86fad465aa0cc9d2e)

    Looks like this needs to be rebased since current version is already 11, and this should be bumping from 11>12.

    Also would suggest bumping version in an initial new commit before the other changes, instead of alongside them. (For example see b15d63e9d81c39d21d7f8040dbb901ca7756da0f from #274.) Splitting should the make the PR a little easier to review, and also allow creating a v11.0 tag that contains every commit MP_MAJOR_VERSION 11 set, and points at a merge.


    enirox001 commented at 3:42 PM on June 3, 2026:

    Done, split the version bumping from the other changes, and included it in the initial commit before other commits


    Sjors commented at 9:41 AM on June 30, 2026:

    Would be good to mention the version bump in the PR description, maybe with a link to #274 so we don't forget to bump it again if that lands earlier.

    It's also useful to point out that the release notes in the first commit are unrelated to the PR itself.


    enirox001 commented at 3:12 PM on June 30, 2026:

    Would be good to mention the version bump in the PR description, maybe with a link to #274 so we don't forget to bump it again if that lands earlier.

    Updated the PR description to mention that this PR bumps it and might change when #274 lands

    It's also useful to point out that the release notes in the first commit are unrelated to the PR itself.

    In https://github.com/bitcoin-core/libmultiprocess/pull/269/commits/188793e6f6f6ea00c469bab5ecff2e77f0cc260e updated the commit message to mention this information

  40. in include/mp/proxy-io.h:882 in 7e51ab2353 outdated
     880 | +{
     881 | +    return state.max_connections && *state.max_connections > 0 ? std::make_unique<EventLoopRef>(loop) : nullptr;
     882 | +}
     883 | +
     884 | +template <typename InitInterface, typename InitImpl>
     885 | +void _Listen(EventLoop& loop, InitImpl& init, const std::shared_ptr<ListenState>& state)
    


    ryanofsky commented at 4:21 PM on June 2, 2026:

    In commit "proxy: add local connection limit to ListenConnections" (7e51ab235330d1a198b910871fac09a23ffdd5f4)

    This function used to have a code comment "//! Given connection receiver and an init object, handle..." that looks like it became detached. Would be good to move it down next this function and update it ("Given init object and a state object containing a connection receiver, handle...").

    It might also be clearer to turn this into a ListenState method. (And if doing that, rename ListenState to Listener, rename listener to m_receiver, rename state to self, make ListenAtCapacity another method, etc). Just an idea though, I haven't studied this code enough yet.


    enirox001 commented at 3:42 PM on June 3, 2026:

    Done. The comment has been moved down above the _Listen function, and its wording has been updated to reflect the new init and state parameters as suggested.

    Regarding refactoring _Listen into a member method of ListenState. I decided to keep it as a free function instead, as it keeps it consistent with other functions in proxy-io.h.

    Would explore this refactor more subsequently

  41. in include/mp/proxy-io.h:865 in 7e51ab2353
     862 | +    return state.max_connections && state.active_connections >= *state.max_connections;
     863 | +}
     864 | +
     865 |  template <typename InitInterface, typename InitImpl>
     866 | -void _Listen(EventLoop& loop, kj::Own<kj::ConnectionReceiver>&& listener, InitImpl& init)
     867 | +void _ServeAccepted(EventLoop& loop, InitImpl& init, const std::shared_ptr<ListenState>& state, kj::Own<kj::AsyncIoStream>&& stream)
    


    ryanofsky commented at 4:36 PM on June 2, 2026:

    In commit "proxy: add local connection limit to ListenConnections" (7e51ab235330d1a198b910871fac09a23ffdd5f4)

    Having an extra _ServeAccepted function that is only called one place seems to make this code more complicated for little benefit. Any reason not to call _Serve directly from _Listen like previous code did?


    enirox001 commented at 3:43 PM on June 3, 2026:

    I agree that this is redundant. I removed the _ServeAccepted helper function entirely and placed the logic directly inside the _Listen callback. This reduces indirection and keeps the flow clean.

  42. in test/mp/test/listen_tests.cpp:213 in 7e51ab2353 outdated
     208 | +    auto client1 = std::make_unique<ClientSetup>(setup.listener.Connect());
     209 | +    setup.WaitForConnectedCount(1);
     210 | +    KJ_EXPECT(client1->client->add(1, 2) == 3);
     211 | +
     212 | +    auto client2 = std::make_unique<ClientSetup>(setup.listener.Connect());
     213 | +    std::this_thread::sleep_for(std::chrono::milliseconds(100));
    


    ryanofsky commented at 4:47 PM on June 2, 2026:

    In commit "proxy: add local connection limit to ListenConnections" (7e51ab235330d1a198b910871fac09a23ffdd5f4)

    Note: Sleep seems racy but harmless since that it shouldn't ever cause the test to fail when it should succeed, only to potentially succeed when it should fail. I can't think of a better way to check that a connection is NOT accepted other than maybe adding a testing_hook that exposes listening state, though so this might be the best approach for now.


    enirox001 commented at 3:43 PM on June 3, 2026:

    Since asserting that the second connection was not accepted inherently requires a brief window of time to pass,

    The short sleep seems to be the most straightforward and reliable approach to test this behavior without adding state tracking hooks. I've left the sleep in place.

  43. in include/mp/proxy-io.h:878 in 7e51ab2353 outdated
     876 | +    });
     877 | +}
     878 | +
     879 | +inline std::unique_ptr<EventLoopRef> _MakeCappedListenerRef(EventLoop& loop, const ListenState& state)
     880 | +{
     881 | +    return state.max_connections && *state.max_connections > 0 ? std::make_unique<EventLoopRef>(loop) : nullptr;
    


    ryanofsky commented at 4:57 PM on June 2, 2026:

    In commit "proxy: add local connection limit to ListenConnections" (7e51ab235330d1a198b910871fac09a23ffdd5f4)

    Would be helpful to have a code comment explaining this logic. I don't think I understand why it avoiding creating an EventLoopRef if there is no connection limit. I'm also not clear on why passing accept_ref around separately is needed. Naively I think I'd just expect ListenState to have an EventLoopRef member.


    enirox001 commented at 3:43 PM on June 3, 2026:

    Thanks, I added explanatory comments to both _MakeCappedListenerRef. I avoid holding EventLoopRef in the uncapped case because the loop is always listening, and holding a ref could prevent it from automatically exiting when all client connections close.

    Passing accept_ref in the callback instead of storing it as a member would bind the ref's lifetime directly to the active accept() task. This means that when the listener reaches capacity, we return early and don't register a new task, which automatically destroys the ref and lets the loop exit.

  44. ryanofsky commented at 5:04 PM on June 2, 2026: collaborator

    Finally spent some time looking at this (7e51ab235330d1a198b910871fac09a23ffdd5f4), and broadly the changes look very good. Left a few questions & suggestions and plan to review more

  45. DrahtBot requested review from ryanofsky on Jun 2, 2026
  46. enirox001 force-pushed on Jun 3, 2026
  47. enirox001 force-pushed on Jun 3, 2026
  48. enirox001 force-pushed on Jun 3, 2026
  49. enirox001 commented at 4:13 PM on June 3, 2026: contributor

    Thanks for the review @ryanofsky, made the following changes in the latest push:

    • Added the KJ_LOG for verbose logging
    • Added a testing_hook_connected hook to ListenSetup.
    • Added thread comments where appropriate
    • Removed the redundant string header
    • Added an initial commit to split the version bumping from other changes
    • Added back the removed comment in the _Listen helper function
    • Removed _ServeAccepted helper function and placed logic in _Listen callback
    • Added explanatory comments to _MakeCappedListenerRef
  50. DrahtBot added the label Needs rebase on Jun 11, 2026
  51. in include/mp/proxy-io.h:869 in 1e8776f3b0
     868 | +    std::optional<size_t> max_connections;
     869 | +    size_t active_connections{0};
     870 | +};
     871 | +
     872 | +template <typename InitInterface, typename InitImpl>
     873 | +void _Listen(EventLoop& loop, InitImpl& init, const std::shared_ptr<ListenState>& state);
    


    xyzconstant commented at 3:08 AM on June 11, 2026:

    Residual forward declaration?


    enirox001 commented at 6:43 AM on June 14, 2026:

    Nice catch, it indeed was a residual forward declaration. I have removed it.

  52. enirox001 force-pushed on Jun 14, 2026
  53. enirox001 commented at 7:26 AM on June 14, 2026: contributor

    In the most recent push. Removed a residual declaration and rebased against the master branch.

    Also added a fix for an IWYU failure in the gen.cpp file. Included here to make the CI pass. Happy to split it into a separate PR if preferred.

  54. DrahtBot removed the label Needs rebase on Jun 14, 2026
  55. enirox001 force-pushed on Jun 29, 2026
  56. enirox001 force-pushed on Jun 29, 2026
  57. enirox001 commented at 1:05 PM on June 29, 2026: contributor

    It might also be clearer to turn this into a ListenState method. (And if doing that, rename ListenState to Listener, rename listener to m_receiver, rename state to self, make ListenAtCapacity another method, etc). Just an idea though, I haven't studied this code enough yet.

    I looked into this refactor more, and it fits better now that the listener has more state to manage. I

    • Renamed ListenState to Listener
    • Renamed the connection receiver to m_receiver
    • Moved the capacity check into Listener::atCapacity()
    • Made the accept loop a Listener::listen() method using self for the shared listener lifetime.

    I also added EventLoop::closeListeners() because capped listeners can hold an EventLoopRef while waiting in accept(). while running interface tests in Bitcoin Core. shutdown reached Shutdown done, but the process did not exit because that pending capped listener ref kept the IPC event loop alive, causing wait_until_stopped() to time out. Closing listeners during shutdown releases that ref and lets the event loop finish cleanly.

  58. in doc/versions.md:10 in e5eba302cd outdated
       6 | @@ -7,9 +7,16 @@ Library versions are tracked with simple
       7 |  Versioning policy is described in the [version.h](../include/mp/version.h)
       8 |  include.
       9 |  
      10 | -## v11
      11 | +## v12
    


    Sjors commented at 9:43 AM on June 30, 2026:

    In e5eba302cddfe7fe3ed020470f98311803a0b1b4 doc/version: Bump version 11 > 12: can you add an explanation to the commit message why this needs a version bump?


    enirox001 commented at 3:07 PM on June 30, 2026:

    Done, updated the commit message to explain why the version bump is needed in https://github.com/bitcoin-core/libmultiprocess/pull/269/commits/188793e6f6f6ea00c469bab5ecff2e77f0cc260e


    ryanofsky commented at 7:13 PM on June 30, 2026:

    re: #269 (review)

    Done, updated the commit message to explain why the version bump is needed in 188793e

    It does seem nice to bump the version number so client code in bitcoin core or elsewhere could potentially decide whether to call ListenConnections with the extra connection limit parameter, or omit it if not supported.

    But I'd tweak the commit message to drop "before bumping the version to v12" because when v11 and v11.0 tags are created they will need to point to last merge commit before this PR. So they won't actually include these release notes. (This is confusing and one reason I want versions.md to move to a separate branch in #287 and #288).

  59. in include/mp/proxy-io.h:298 in 2cfc3b4ad4 outdated
     293 | @@ -293,6 +294,9 @@ class EventLoop
     294 |      //! Check if loop should exit.
     295 |      bool done() const MP_REQUIRES(m_mutex);
     296 |  
     297 | +    //! Stop accepting new incoming connections.
     298 | +    void closeListeners();
    


    Sjors commented at 12:08 PM on June 30, 2026:

    In 2cfc3b4ad41695e810ac7b6e43e00f59a393d0cb mpgen: add missing includes for IWYU compliance: this commit is doing a lot more than adding missing includes.


    enirox001 commented at 3:09 PM on June 30, 2026:

    Thanks for spotting this, intended to commit to https://github.com/bitcoin-core/libmultiprocess/pull/269/commits/64ff2fe25f9af4343f98c1e399b55e3069748958, but it seems I amended to HEAD instead

    Reverted this and moved the changes to the appropriate commit

  60. enirox001 force-pushed on Jun 30, 2026
  61. enirox001 force-pushed on Jun 30, 2026
  62. in include/mp/proxy-types.h:658 in 8d09ad37db
     654 | @@ -655,7 +655,14 @@ struct CapRequestTraits<::capnp::Request<_Params, _Results>>
     655 |  template <typename Client>
     656 |  void clientDestroy(Client& client)
     657 |  {
     658 | -    MP_LOG(*client.m_context.loop, Log::Debug) << "IPC client destroy " << CxxTypeName(client);
     659 | +    // Lock is needed because the sync cleanup callback on the event loop
    


    ryanofsky commented at 7:21 PM on June 30, 2026:

    In commit "proxy: add local connection limit to ListenConnections" (8d09ad37dbf4fdcbc00fb6b6357fc968825db9f4)

    This change should be unnecessary because logging no longer requires the connection object (since 315ff537fb6550468c3148e795dffb78313bd11f). So this change should be reverted. Looks like this might have come from a conflict with #286


    enirox001 commented at 4:35 PM on July 1, 2026:

    Done, changed this so it no longer checks m_context.connection and only logs through m_context.loop

  63. in include/mp/proxy-io.h:298 in 8d09ad37db
     293 | @@ -291,6 +294,9 @@ class EventLoop
     294 |      //! Check if loop should exit.
     295 |      bool done() const MP_REQUIRES(m_mutex);
     296 |  
     297 | +    //! Stop accepting new incoming connections.
     298 | +    void closeListeners();
    


    ryanofsky commented at 7:25 PM on June 30, 2026:

    In commit "proxy: add local connection limit to ListenConnections" (8d09ad37dbf4fdcbc00fb6b6357fc968825db9f4)

    I don't think this extra tracking of listeners should be necessary and looks like this implementation could be simplified significantly. Would suggest:

    <details><summary>diff</summary> <p>

    --- a/include/mp/proxy-io.h
    +++ b/include/mp/proxy-io.h
    @@ -294,9 +294,6 @@ public:
         //! Check if loop should exit.
         bool done() const MP_REQUIRES(m_mutex);
     
    -    //! Stop accepting new incoming connections.
    -    void closeListeners();
    -
         //! Process name included in thread names so combined debug output from
         //! multiple processes is easier to understand.
         const char* m_exe_name;
    @@ -341,9 +338,6 @@ public:
         //! List of connections.
         std::list<Connection> m_incoming_connections;
     
    -    //! List of socket listeners.
    -    std::list<std::shared_ptr<Listener>> m_listeners;
    -
         //! Logging options
         LogOptions m_log_opts;
     
    @@ -872,17 +866,6 @@ struct Listener
             return m_max_connections && m_active_connections >= *m_max_connections;
         }
     
    -    void holdAcceptRef(EventLoop& loop)
    -    {
    -        if (m_max_connections && *m_max_connections > 0) m_accept_ref.emplace(loop);
    -    }
    -
    -    void close()
    -    {
    -        m_closed = true;
    -        m_accept_ref.reset();
    -    }
    -
         //! Handle incoming connections by calling _Serve, to create ProxyServer
         //! objects and forward requests to the init object.
         template <typename InitInterface, typename InitImpl>
    @@ -890,31 +873,23 @@ struct Listener
     
         kj::Own<kj::ConnectionReceiver> m_receiver;
         std::optional<size_t> m_max_connections;
    -    std::optional<EventLoopRef> m_accept_ref;
         size_t m_active_connections{0};
    -    bool m_closed{false};
     };
     
     template <typename InitInterface, typename InitImpl>
     void Listener::listen(EventLoop& loop, InitImpl& init, const std::shared_ptr<Listener>& self)
     {
    -    if (m_closed || atCapacity()) return;
    +    if (atCapacity()) return;
     
         auto* receiver = m_receiver.get();
    -    // Capped listeners need to keep the event loop alive while below capacity
    -    // and waiting for another connection. Store the ref on the Listener so
    -    // closeListeners() can release it during process shutdown.
    -    holdAcceptRef(loop);
         loop.m_task_set->add(receiver->accept().then(
             [&loop, &init, self](kj::Own<kj::AsyncIoStream>&& stream) {
    -            self->m_accept_ref.reset();
    -            if (self->m_closed) return;
                 ++self->m_active_connections;
                 _Serve<InitInterface>(loop, kj::mv(stream), init, [&loop, &init, self] {
                     const bool resume_accept{self->atCapacity()};
                     assert(self->m_active_connections > 0);
                     --self->m_active_connections;
    -                if (resume_accept && !self->m_closed) self->listen<InitInterface>(loop, init, self);
    +                if (resume_accept) self->listen<InitInterface>(loop, init, self);
                 });
                 self->listen<InitInterface>(loop, init, self);
             }));
    @@ -941,7 +916,6 @@ void ListenConnections(EventLoop& loop, int fd, InitImpl& init, std::optional<si
             auto listener{std::make_shared<Listener>(
                 loop.m_io_context.lowLevelProvider->wrapListenSocketFd(fd, kj::LowLevelAsyncIoProvider::TAKE_OWNERSHIP),
                 max_connections)};
    -        loop.m_listeners.push_back(listener);
             listener->listen<InitInterface>(loop, init, listener);
         });
     }
    --- a/src/mp/proxy.cpp
    +++ b/src/mp/proxy.cpp
    @@ -324,14 +324,6 @@ bool EventLoop::done() const
         return m_num_clients == 0 && m_async_fns->empty();
     }
     
    -void EventLoop::closeListeners()
    -{
    -    assert(std::this_thread::get_id() == m_thread_id);
    -    for (auto& listener : m_listeners) {
    -        listener->close();
    -    }
    -}
    -
     std::tuple<ConnThread, bool> SetThread(GuardedRef<ConnThreads> threads, Connection* connection, const std::function<Thread::Client()>& make_thread)
     {
         assert(std::this_thread::get_id() == connection->m_loop->m_thread_id);
    --- a/test/mp/test/listen_tests.cpp
    +++ b/test/mp/test/listen_tests.cpp
    @@ -121,7 +121,7 @@ class ListenSetup
     {
     public:
         explicit ListenSetup(std::optional<size_t> max_connections = std::nullopt)
    -        : capped_listener(max_connections.has_value()), thread([this, max_connections] {
    +        : thread([this, max_connections] {
                   EventLoop loop("mptest-server", [this](mp::LogMessage log) {
                       KJ_LOG(INFO, log.level, log.message);
                       if (log.level == mp::Log::Raise) throw std::runtime_error(log.message);
    @@ -136,10 +136,7 @@ public:
                       ++connected_count;
                       counter_cv.notify_all();
                   };
    -              {
    -                  std::lock_guard<std::mutex> lock(counter_mutex);
    -                  event_loop = &loop;
    -              }
    +              m_loop_ref.emplace(loop);
                   FooImplementation foo;
                   ListenConnections<messages::FooInterface>(loop, listener.release(), foo, max_connections);
                   ready_promise.set_value();
    @@ -151,14 +148,7 @@ public:
     
         ~ListenSetup()
         {
    -        if (capped_listener) {
    -            EventLoop* loop;
    -            {
    -                std::lock_guard<std::mutex> lock(counter_mutex);
    -                loop = event_loop;
    -            }
    -            if (loop) loop->sync([&] { loop->closeListeners(); });
    -        }
    +        m_loop_ref.reset();
             thread.join();
         }
     
    @@ -190,10 +180,9 @@ public:
     
         UnixListener listener;
         std::promise<void> ready_promise;
    -    bool capped_listener{false};
    +    std::optional<EventLoopRef> m_loop_ref;
         std::mutex counter_mutex;
         std::condition_variable counter_cv;
    -    EventLoop* event_loop{nullptr};
         size_t connected_count{0};
         size_t disconnected_count{0};
         //! Thread variable should be after other struct members so the thread does
    

    </p> </details>

    However, one thing I am just realizing about the ListenConnections API is there is no way to stop listening after you have started listening without shutting down the entire event loop. This is ok for bitcoin core right now, since it only needs to start listening on startup and stop on shutdown. But for other applications it could make sense to be able to stop listening for new connections without shutting down existing ones. This could be implemented by making ListenConnections return shared_ptr<Listener> instead of void and giving Listener a close method. But that feature wouldn't be worth extra complexity in this commit, and also might make more sense as a separate PR.


    enirox001 commented at 4:35 PM on July 1, 2026:

    I don't think this extra tracking of listeners should be necessary and looks like this implementation could be simplified significantly.

    Done, I dropped the extra listener tracking and shutdown state. The listener now owns the receiver, optional max, and active count, and resumes accepting from the disconnect callback when capacity opens up. Thanks

    However, one thing I am just realizing about the ListenConnections API is there is no way to stop listening after you have started listening without shutting down the entire event loop. This is ok for bitcoin core right now, since it only needs to start listening on startup and stop on shutdown. But for other applications it could make sense to be able to stop listening for new connections without shutting down existing ones. This could be implemented by making ListenConnections return shared_ptr<Listener> instead of void and giving Listener a close method. But that feature wouldn't be worth extra complexity in this commit, and also might make more sense as a separate PR.

    That makes sense. I’m leaving stop listening support out of this PR, so this stays focused on the local connection limit. Returning a listener handle with close() sounds like a better follow up change if another caller needs it.

  64. in src/mp/gen.cpp:357 in e86184beed
     351 | @@ -352,7 +352,9 @@ static void Generate(kj::StringPtr src_prefix,
     352 |      cpp_types << "#include <" << include_path << ".h> // IWYU pragma: keep\n";
     353 |      cpp_types << "#include <" << include_path << ".proxy.h>\n";
     354 |      cpp_types << "#include <" << include_path << ".proxy-types.h> // IWYU pragma: keep\n";
     355 | -    cpp_types << "#include <" << PROXY_TYPES << ">\n\n";
     356 | +    cpp_types << "#include <kj/common.h>\n";
     357 | +    cpp_types << "#include <" << PROXY_TYPES << ">\n";
     358 | +    cpp_types << "#include \"mp/util.h\"\n\n";
    


    ryanofsky commented at 7:40 PM on June 30, 2026:

    In commit "mpgen: add missing includes for IWYU compliance" (e86184beed12f5b85c43ffb0636faf6141119714)

    Should probably use <> instead of "" include syntax here like in the other include lines


    enirox001 commented at 4:35 PM on July 1, 2026:

    Done. Changed the generated include to use angle brackets: #include <mp/util.h>.

  65. in test/mp/test/listen_tests.cpp:233 in e86184beed outdated
     228 | +
     229 | +    KJ_EXPECT(client2->client->add(2, 3) == 5);
     230 | +
     231 | +    client2.reset();
     232 | +    setup.WaitForDisconnectedCount(2);
     233 | +    std::this_thread::sleep_for(std::chrono::milliseconds(100));
    


    ryanofsky commented at 7:55 PM on June 30, 2026:

    In commit "proxy: add local connection limit to ListenConnections" (8d09ad37dbf4fdcbc00fb6b6357fc968825db9f4)

    I understand point of sleep above is to make sure KJ_EXPECT(setup.ConnectedCount() == 1); does not pass accidentally due to timing even if max_connections implementation is broken.

    But I don't understand reason this sleep is useful. Would be good to add a comment if it is helpful, or drop otherwise


    enirox001 commented at 4:36 PM on July 1, 2026:

    This indeed has no use here, dropped this sleep since the following WaitForConnectedCount(3) already verifies that accepting resumed.


    ryanofsky commented at 5:30 PM on July 5, 2026:

    re: #269 (review)

    This indeed has no use here, dropped this sleep since the following WaitForConnectedCount(3) already verifies that accepting resumed.

    I seem to still see the sleep in db81e9c5a832ddda3449c30e29b9feb4ba85922b (line 223)


    enirox001 commented at 2:49 PM on July 6, 2026:

    Replaced this sleep with (**setup.m_loop_ref).sync([] {}) with the comment above it explaining why is is used as well

  66. in test/mp/test/listen_tests.cpp:222 in e86184beed outdated
     217 | +    auto client1 = std::make_unique<ClientSetup>(setup.listener.Connect());
     218 | +    setup.WaitForConnectedCount(1);
     219 | +    KJ_EXPECT(client1->client->add(1, 2) == 3);
     220 | +
     221 | +    auto client2 = std::make_unique<ClientSetup>(setup.listener.Connect());
     222 | +    std::this_thread::sleep_for(std::chrono::milliseconds(100));
    


    ryanofsky commented at 8:03 PM on June 30, 2026:

    In commit "test: add dedicated ListenConnections coverage" (3547a26c61aa5741559adb615d259497fc5a2f52)

    I think you could replace this sleep with a call to (**m_loop_ref).sync([] {}); or similar. Test seems to fail reliably with sync instead of sleep when max_connections is increased, so I think the test is still ensures the limit is enforced. Would also suggest a comment for the sleep/sync like "Without this delay, ConnectedCount() == 1 might pass even max_connections was not enforced"


    enirox001 commented at 4:36 PM on July 1, 2026:

    Done. replaced the sleep with (**setup.m_loop_ref).sync([] {}) and added a comment explaining that the sync gives the event loop a chance to accept the second client if max_connections is not being enforced.


    ryanofsky commented at 5:32 PM on July 5, 2026:

    re: #269 (review)

    Done. replaced the sleep with (**setup.m_loop_ref).sync([] {}) and added a comment explaining that the sync gives the event loop a chance to accept the second client if max_connections is not being enforced.

    I see this is added in a new test but existing sleep seems to remain in db81e9c5a832ddda3449c30e29b9feb4ba85922b line 212


    enirox001 commented at 2:49 PM on July 6, 2026:

    Removed this sleep entirely.

    The following WaitForConnectedCount(3) already verifies that accepting resumed after the previous client disconnected, so no extra delay is needed here.

  67. in test/mp/test/listen_tests.cpp:240 in e86184beed outdated
     235 | +    auto client3 = std::make_unique<ClientSetup>(setup.listener.Connect());
     236 | +    setup.WaitForConnectedCount(3);
     237 | +    KJ_EXPECT(client3->client->add(3, 4) == 7);
     238 | +}
     239 | +
     240 | +KJ_TEST("ListenConnections keeps capped listeners alive before reaching the limit")
    


    ryanofsky commented at 8:07 PM on June 30, 2026:

    In commit "test: add dedicated ListenConnections coverage" (3547a26c61aa5741559adb615d259497fc5a2f52)

    This test just seems to be testing what happens when there is a connection, a disconnect, and then another connect serially so do does not seem to be cover anything the previous test doesn't cover. It might make more sense for this test to create two connections at the same time and make sure both work, but that a third connection doesn't work. This way there is coverage of connections working in parallel with the limit.


    enirox001 commented at 4:36 PM on July 1, 2026:

    Added this test in https://github.com/bitcoin-core/libmultiprocess/pull/269/commits/db81e9c5a832ddda3449c30e29b9feb4ba85922b

    It verifies that when two clients are connected and served concurrently with max_connections=2, a third client is not accepted while the listener is at capacity, and that the third client is accepted only after one active client disconnects.


    ryanofsky commented at 5:38 PM on July 5, 2026:

    re: #269 (review)

    In commit "proxy: add local connection limit to ListenConnections" (db81e9c5a832ddda3449c30e29b9feb4ba85922b)

    Added this test in db81e9c

    Thanks for adding the new test, but suggestion was really to expand the "ListenConnections keeps capped listeners alive before reaching the limit" test to do this, because this test doesn't seem to test anything new that the previous "enforces a local connection limit" test doesn't cover.

    So now would suggest keeping the "enforces" test and "parallel connections" tests but dropping the "capped listeners" test.


    enirox001 commented at 2:50 PM on July 6, 2026:

    but suggestion was really to expand the "ListenConnections keeps capped listeners alive before reaching the limit" test to do this, because this test doesn't seem to test anything new that the previous "enforces a local connection limit" test doesn't cover.

    After adding the "multiple connections" test, kept this test as well because i thought it would be nice to have the added coverage.

    But like you mentioned. This is a redundant test that doesn't cover any distinct functionality. I have removed it.

  68. ryanofsky approved
  69. ryanofsky commented at 8:25 PM on June 30, 2026: collaborator

    Code review e86184beed12f5b85c43ffb0636faf6141119714. Sorry again for the long delay reviewing this and I will try to be more responsive. This PR seems easier to understand with the new refactoring, so thanks for making that change. I suggested another simplification below which I think would be important to make, but overall the change looks very good.

  70. enirox001 force-pushed on Jul 1, 2026
  71. enirox001 force-pushed on Jul 1, 2026
  72. enirox001 force-pushed on Jul 1, 2026
  73. enirox001 force-pushed on Jul 1, 2026
  74. enirox001 force-pushed on Jul 1, 2026
  75. enirox001 commented at 4:35 PM on July 1, 2026: contributor

    Thanks for the detailed review @ryanofsky

    But I'd tweak the commit message to drop "before bumping the version to v12" because when v11 and v11.0 tags are created they will need to point to last merge commit before this PR. So they won't actually include these release notes.

    Reworded the commit message to avoid implying the v11 notes are included in this PR’s release. It now says the v11 notes describe changes that will be tagged before this PR.

    Also made the changes requested in the latest push.

    I dropped the mpgen IWYU commit because it was adding includes to generated files that IWYU reported as unused. The actual IWYU issue was that listen_tests.cpp uses EventLoopRef directly, so I fixed that by including mp/proxy.h in listen_tests.cpp

  76. in test/mp/test/listen_tests.cpp:119 in 922bfbac39 outdated
     114 | +    //! Thread variable should be after other struct members so the thread does
     115 | +    //! not start until the other members are initialized.
     116 | +    std::thread thread;
     117 | +};
     118 | +
     119 | +class ListenSetup
    


    Sjors commented at 12:58 PM on July 2, 2026:

    In 922bfbac39382643201ddd10dd8151e0b802f0ae test: add dedicated ListenConnections coverage: it would be good to briefly document the ListenSetup, ClientSetup and UnixListener classes - and their relationship.


    Eunovo commented at 2:11 PM on July 2, 2026:

    https://github.com/bitcoin-core/libmultiprocess/pull/269/commits/922bfbac39382643201ddd10dd8151e0b802f0ae: I think we can merge ClientSetup and ListenSetup into one TestSetup class. The clients and listener can use the same EventLoop instance.


    Sjors commented at 6:51 AM on July 3, 2026:

    I'm not sure if that makes things easier to follow? I would expect the server to have its own EventLoop.


    Eunovo commented at 7:58 AM on July 3, 2026:

    I'm not sure if that makes things easier to follow?

    It's not necessary, but it's less code.

    I would expect the server to have its own EventLoop.

    Indeed, but it does not matter in this test. We can have less code if they shared the same EventLoop. The same pattern is used in existing test setup https://github.com/bitcoin-core/libmultiprocess/blob/master/test/mp/test/test.cpp#L64

    I'm angling for less code overall, but this works too.


    ryanofsky commented at 5:51 PM on July 5, 2026:

    re: #269 (review)

    I'd agree with with suggestion to reduce code duplication and share one event loop. I think having one eventloop in tests is generally better because it adds creates more tasks and dependencies for the async code to keep track of and is more likely to break if things don't happen in the right order.

    But I also think it's good to have some variety in tests and not always use one event loop, so either way seem ok.


    enirox001 commented at 2:50 PM on July 6, 2026:

    We can have less code if they shared the same EventLoop. The same pattern is used in existing test setup https://github.com/bitcoin-core/libmultiprocess/blob/master/test/mp/test/test.cpp#L64

    I think this is a good idea, and I spent some time thinking about it and playing around with an implementation. But after trying it, I think using the same pattern as TestSetup makes this fixture harder to reason about.

    The existing TestSetup pattern works well for in-memory client to server tests, where the test is mostly checking serialization and application behavior like “if I call add(1, 2), does it reach the server and return 3?”

    These ListenConnections tests are different because they are exercising a real listening socket and multiple independent clients connecting to it. For that, I think keeping ClientSetup and ListenSetup separate makes the client and listener roles explicit and keeps the test closer to the behavior being tested which is a listener running on one side, and clients connecting from outside.

    I agree this is a little more code, but I think the separation buys clarity here. So I’d prefer to keep this structure unless there’s a concrete simplification that preserves that separation.


    enirox001 commented at 2:51 PM on July 6, 2026:

    Done. Added short comments in https://github.com/bitcoin-core/libmultiprocess/pull/269/commits/ef3567904b57b0c83fb59bedead1d88ed5d022bf explaining the functionality as well as the relationships of these classes.

    In the same vein, i have added some comments to some of the non trivial tests in listen_tests.cpp file. This should make it easier to understand and follow

  77. in test/mp/test/listen_tests.cpp:129 in db81e9c5a8
     126 | +    explicit ListenSetup(std::optional<size_t> max_connections = std::nullopt)
     127 | +        : thread([this, max_connections] {
     128 |                EventLoop loop("mptest-server", [this](mp::LogMessage log) {
     129 |                    KJ_LOG(INFO, log.level, log.message);
     130 |                    if (log.level == mp::Log::Raise) throw std::runtime_error(log.message);
     131 | +                  if (log.message.find("IPC server: socket disconnected.") != std::string::npos) {
    


    Eunovo commented at 1:48 PM on July 2, 2026:

    https://github.com/bitcoin-core/libmultiprocess/pull/269/commits/db81e9c5a832ddda3449c30e29b9feb4ba85922b

    Looks like this same pattern works for connected_count, and we don't need the testing_hook_connected:

    if (log.message.find("IPC server: socket connected.") != std::string::npos) {
        std::lock_guard<std::mutex> lock(counter_mutex);
        ++connected_count;
        counter_cv.notify_all();
    }
    

    Sjors commented at 6:52 AM on July 3, 2026:

    I think a testing hook is cleaner than parsing log strings.


    Eunovo commented at 8:00 AM on July 3, 2026:

    I think a testing hook is cleaner than parsing log strings.

    Indeed, but I prefer that we don't add testing hooks unnecessarily. Besides, he is already parsing the disconnected log string; might as well parse the connected log string.


    ryanofsky commented at 5:43 PM on July 5, 2026:

    re: #269 (review)

    Indeed, but I prefer that we don't add testing hooks unnecessarily.

    Would be curious to if there's any particular reason why. I feel like they add very little overhead and have similar side-benefits as log messages in making code more readable and grep-able. I would definitely not shy away from them in cases where they can add better test coverage and more compile time safety than log string checks.


    enirox001 commented at 2:50 PM on July 6, 2026:

    I understand why we would want to parse the log string instead of the testing hook, but i do not think there is much downside to adding a testing hook.

    I think it makes the code much cleaner, and is lean enough that it wouldn't cause much problems by using it

    I will keep the connected hook because it avoids making the test depend on log message text to make the test consistent and avoid string parsing entirely,

    Also added a disconnected_hook and switched disconnected_count to use that instead.


    Eunovo commented at 1:56 PM on July 7, 2026:

    SGTM

  78. in test/mp/test/listen_tests.cpp:132 in 922bfbac39 outdated
     127 | +              });
     128 | +              loop.testing_hook_connected = [&] {
     129 | +                  std::lock_guard<std::mutex> lock(counter_mutex);
     130 | +                  ++connected_count;
     131 | +                  counter_cv.notify_all();
     132 | +              };
    


    Sjors commented at 2:10 PM on July 2, 2026:

    In 922bfbac39382643201ddd10dd8151e0b802f0ae test: add dedicated ListenConnections coverage: I think it's better to introduce m_loop_ref here rather than in the next commit. I found myself wondering why the loop doesn't immediately self-destruct before the first client connects. It doesn't, for subtle reasons. Explicitly taking a reference makes it more clear, especially combined with #302.

    diff --git a/test/mp/test/listen_tests.cpp b/test/mp/test/listen_tests.cpp
    index 86e6421777..9629ce69d0 100644
    --- a/test/mp/test/listen_tests.cpp
    +++ b/test/mp/test/listen_tests.cpp
    @@ -18,6 +18,8 @@
     #include <kj/test.h>
     #include <memory>
    +#include <mp/proxy.h>
     #include <mp/proxy-io.h>
     #include <mutex>
    +#include <optional>
     #include <ratio> // IWYU pragma: keep
     #include <stdexcept>
    @@ -131,4 +133,5 @@ public:
                       counter_cv.notify_all();
                   };
    +              m_loop_ref.emplace(loop);
                   FooImplementation foo;
                   ListenConnections<messages::FooInterface>(loop, listener.release(), foo);
    @@ -142,4 +145,5 @@ public:
         ~ListenSetup()
         {
    +        m_loop_ref.reset();
             thread.join();
         }
    @@ -157,4 +161,5 @@ public:
         UnixListener listener;
         std::promise<void> ready_promise;
    +    std::optional<EventLoopRef> m_loop_ref;
         std::mutex counter_mutex;
         std::condition_variable counter_cv;
    

    enirox001 commented at 2:51 PM on July 6, 2026:

    Good point. I have moved the m_loop_ref setup into the initial listen_tests.cpp commit in https://github.com/bitcoin-core/libmultiprocess/pull/269/commits/ef3567904b57b0c83fb59bedead1d88ed5d022bf so the listener event loop lifetime is explicit from the start.

    The subsequent max_connections tests still use the same m_loop_ref for event-loop synchronization, but the commit now owns the reference before those tests are introduced

  79. in test/mp/test/listen_tests.cpp:247 in db81e9c5a8
     242 | +    auto client2 = std::make_unique<ClientSetup>(setup.listener.Connect());
     243 | +    setup.WaitForConnectedCount(2);
     244 | +    KJ_EXPECT(client2->client->add(2, 3) == 5);
     245 | +}
     246 | +
     247 | +KJ_TEST("ListenConnections accepts parallel connections")
    


    Eunovo commented at 2:18 PM on July 2, 2026:

    https://github.com/bitcoin-core/libmultiprocess/pull/269/commits/db81e9c5a832ddda3449c30e29b9feb4ba85922b:

    The connections in this test look completely synchronous to me. A better simulation of simultaneous connection requests can be created with std::thread and std::barrier. I also think it's better to make >2 simultaneous requests and test that only 2 were accepted.

    You can also test simultaneous disconnection. Although it should be noted that these tests will pass because the kernel will queue the connection and disconnection events, and even if it didn't, Listener::listen processes the connection requests one at a time, and the active connections count is decremented on the event loop, which should make it "thread-safe".


    ryanofsky commented at 5:56 PM on July 5, 2026:

    re: #269 (review)

    db81e9c:

    A better simulation of simultaneous connection requests can be created with [...]

    These are good ideas. Might be better to change the current test name to refer "multiple" connections instead of "parallel", since the connections are established and calls are made serially. I suggested this test in #269 (review) and I think having it is better than not having it, but it could be extended to do more.


    enirox001 commented at 2:51 PM on July 6, 2026:

    Might be better to change the current test name to refer "multiple" connections instead of "parallel", since the connections are established and calls are made serially

    Renamed the test to refer to multiple active connections instead of parallel connections. agree “parallel” overstated what it was doing because the connection setup and calls are serial.

    A better simulation of simultaneous connection requests can be created with std::thread and std::barrier. I also think it's better to make >2 simultaneous requests and test that only 2 were accepted.

    I’m a bit averse to adding a barrier-based simultaneous-connect test right now because I think it would mostly exercise kernel socket backlog behavior and event loop scheduling.

    Listener::listen still processes accepted streams on the event loop one at a time, and m_active_connections is updated on that same even -loop thread.

    So the existing test covers the important listener behavior which is multiple active connections are allowed up to the configured limit, a further connection is not accepted while at capacity, and accepting resumes after a disconnect.

  80. in test/mp/test/listen_tests.cpp:230 in db81e9c5a8
     225 | +    auto client3 = std::make_unique<ClientSetup>(setup.listener.Connect());
     226 | +    setup.WaitForConnectedCount(3);
     227 | +    KJ_EXPECT(client3->client->add(3, 4) == 7);
     228 | +}
     229 | +
     230 | +KJ_TEST("ListenConnections keeps capped listeners alive before reaching the limit")
    


    Eunovo commented at 2:19 PM on July 2, 2026:

    https://github.com/bitcoin-core/libmultiprocess/pull/269/commits/db81e9c5a832ddda3449c30e29b9feb4ba85922b:

    I'm not sure what this test is trying to achieve; can you explain?


    ryanofsky commented at 5:53 PM on July 5, 2026:

    re: #269 (review)

    I'm not sure what this test is trying to achieve; can you explain?

    I think this test is a holdover from before the suggested lifetime simplification #269 (review) and I suggested deleting in #269 (review)


    enirox001 commented at 2:51 PM on July 6, 2026:

    I'm not sure what this test is trying to achieve; can you explain?

    As mentioned above by @ryanofsky this test was added when the initial listeners functionality were added. But this has been simplified, but it seems i had not removed it.

    This has been removed in https://github.com/bitcoin-core/libmultiprocess/pull/269/commits/dcf9c38eb41ee801f2842dfa35986ef8335ffdba

  81. in test/mp/test/listen_tests.cpp:170 in 922bfbac39
     165 | +    
     166 | +};
     167 | +
     168 | +KJ_TEST("ListenConnections accepts incoming connections")
     169 | +{
     170 | +    ListenSetup setup;
    


    Sjors commented at 2:20 PM on July 2, 2026:

    In 922bfbac39382643201ddd10dd8151e0b802f0ae test: add dedicated ListenConnections coverage: maybe call it server, so the next line is easier to read.


    enirox001 commented at 2:52 PM on July 6, 2026:

    Done, renames this to server.

    This pattern is repeated in the subsequent commit, so i also renamed them to server as well. This makes the tests easier to read and understand

  82. in test/mp/test/listen_tests.cpp:171 in 922bfbac39
     166 | +};
     167 | +
     168 | +KJ_TEST("ListenConnections accepts incoming connections")
     169 | +{
     170 | +    ListenSetup setup;
     171 | +    auto client = std::make_unique<ClientSetup>(setup.listener.Connect());
    


    Sjors commented at 2:34 PM on July 2, 2026:

    In 922bfbac39382643201ddd10dd8151e0b802f0ae test: add dedicated ListenConnections coverage: maybe rename to MakeConnectedSocket()


    enirox001 commented at 2:52 PM on July 6, 2026:

    Done. Renamed UnixListener::Connect() to MakeConnectedSocket() to make it clearer that the helper returns a connected socket file descriptor.

  83. Eunovo commented at 2:36 PM on July 2, 2026: contributor
  84. Sjors commented at 2:36 PM on July 2, 2026: member

    Feedback on the first commit.

  85. in test/mp/test/listen_tests.cpp:34 in 922bfbac39 outdated
      29 | +
      30 | +namespace mp {
      31 | +namespace test {
      32 | +namespace {
      33 | +
      34 | +class UnixListener
    


    ryanofsky commented at 4:32 PM on July 5, 2026:

    In commit "test: add dedicated ListenConnections coverage" (922bfbac39382643201ddd10dd8151e0b802f0ae)

    This is fine for this PR, but would be interesting for a followup to see if this could be replaced using libkj IO functions to listen on a random TCP port or unix socket path in a more portable way that could work on windows. Might allow code to be simplified too


    enirox001 commented at 2:52 PM on July 6, 2026:

    Noted. Seeing that this is open #274 to add non unix support. This is something i intend to look into

    Using libkj IO helpers for a more portable temporary listener setup seems a good starting point, especially if it can also reduce the socket setup code here.

  86. in include/mp/proxy-io.h:916 in db81e9c5a8 outdated
     914 | -void ListenConnections(EventLoop& loop, int fd, InitImpl& init)
     915 | +void ListenConnections(EventLoop& loop, int fd, InitImpl& init, std::optional<size_t> max_connections = std::nullopt)
     916 |  {
     917 |      loop.sync([&]() {
     918 | -        _Listen<InitInterface>(loop,
     919 | +        auto listener{std::make_shared<Listener>(
    


    ryanofsky commented at 4:38 PM on July 5, 2026:

    In commit "proxy: add local connection limit to ListenConnections" (db81e9c5a832ddda3449c30e29b9feb4ba85922b)

    Just want to note for followup that it will probably make sense to return this listener shared_ptr to the caller to allow it to stop listening. Right now there isn't (and has never been) a way to stop listening without shutting down the entire event loop, but this could now be supported with the listener object. (see also #269 (review))


    enirox001 commented at 2:52 PM on July 6, 2026:

    Having a way to make a stop a listener from listening is something that should definitiely be supported. And returning the std::shared_ptr<Listener> later as a way to potentially do that seems like a good starting point.

  87. ryanofsky approved
  88. ryanofsky commented at 6:00 PM on July 5, 2026: collaborator

    Code review ACK db81e9c5a832ddda3449c30e29b9feb4ba85922b. Looks good! Main change is simplifying lifetime code and dropping m_listeners list since last review. Would be good to respond to remaining comments though.

  89. DrahtBot requested review from xyzconstant on Jul 5, 2026
  90. DrahtBot requested review from Eunovo on Jul 5, 2026
  91. ryanofsky referenced this in commit 16bf05dea0 on Jul 5, 2026
  92. enirox001 force-pushed on Jul 6, 2026
  93. enirox001 force-pushed on Jul 6, 2026
  94. enirox001 force-pushed on Jul 6, 2026
  95. enirox001 force-pushed on Jul 6, 2026
  96. enirox001 force-pushed on Jul 6, 2026
  97. enirox001 commented at 2:52 PM on July 6, 2026: contributor

    Thanks for all the reviews so far @Eunovo @Sjors @ryanofsky . Addressed all the comments in the latest commits.

  98. in test/mp/test/listen_tests.cpp:68 in ef3567904b outdated
      63 | +        if (!m_dir.empty()) rmdir(m_dir.c_str());
      64 | +    }
      65 | +
      66 | +    int release()
      67 | +    {
      68 | +        int fd = m_fd;
    


    Eunovo commented at 2:25 PM on July 7, 2026:

    https://github.com/bitcoin-core/libmultiprocess/pull/269/commits/ef3567904b57b0c83fb59bedead1d88ed5d022bf:

    Not necessarily important because this is test code, but you can assert m_fd >= 0 before releasing here.


    enirox001 commented at 11:26 AM on July 8, 2026:

    Added, seems good to have.

  99. in test/mp/test/listen_tests.cpp:226 in dcf9c38eb4
     221 | +    server.WaitForConnectedCount(1);
     222 | +    KJ_EXPECT(client1->client->add(1, 2) == 3);
     223 | +
     224 | +    auto client2 = std::make_unique<ClientSetup>(server.listener.MakeConnectedSocket());
     225 | +
     226 | +    // Without this sync, ConnectedCount() == 2 might pass even if
    


    Eunovo commented at 2:44 PM on July 7, 2026:

    https://github.com/bitcoin-core/libmultiprocess/pull/269/commits/dcf9c38eb41ee801f2842dfa35986ef8335ffdba:

    I think you meant // Without this sync, ConnectedCount() == 1 might pass even if


    enirox001 commented at 11:26 AM on July 8, 2026:

    Taken, have renamed this

  100. in test/mp/test/listen_tests.cpp:228 in dcf9c38eb4
     223 | +
     224 | +    auto client2 = std::make_unique<ClientSetup>(server.listener.MakeConnectedSocket());
     225 | +
     226 | +    // Without this sync, ConnectedCount() == 2 might pass even if
     227 | +    // max_connections was not enforced because the event loop has not accepted
     228 | +    // client3 yet.
    



    enirox001 commented at 11:26 AM on July 8, 2026:

    Taken thanks.

  101. DrahtBot requested review from ryanofsky on Jul 7, 2026
  102. in include/mp/proxy-io.h:360 in ef3567904b
     355 | @@ -356,6 +356,9 @@ class EventLoop
     356 |  
     357 |      //! Hook called on the worker thread just before returning results.
     358 |      std::function<void()> testing_hook_async_request_done;
     359 | +
     360 | +    //! Hook called on the server thread when the client has connected.
    


    ryanofsky commented at 3:03 PM on July 7, 2026:

    In commit "test: add dedicated ListenConnections coverage" (ef3567904b57b0c83fb59bedead1d88ed5d022bf)

    Would change "server thread" to "event loop thread". Saying server thread is a little confusing because typically there's only one event loop and it processes all events on a single thread, not distinguishing between client and server events.

    Would also change "the client" to "a client" since there may be more than one.

    Same suggestion also applies to next commit adding disconnect hook.


    enirox001 commented at 11:26 AM on July 8, 2026:

    I agree, this does makes things clearer, the previous comment here does infer to having multiple server threads and a single client. Both of which are not correct.

    I have applied the suggested changes.

  103. in test/mp/test/listen_tests.cpp:42 in ef3567904b
      37 | +class UnixListener
      38 | +{
      39 | +public:
      40 | +    UnixListener()
      41 | +    {
      42 | +        char dir_template[] = "/tmp/mptest-listener-XXXXXX";
    


    ryanofsky commented at 3:15 PM on July 7, 2026:

    In commit "test: add dedicated ListenConnections coverage" (ef3567904b57b0c83fb59bedead1d88ed5d022bf)

    Hardcoding /tmp is probably fine because this is just creating a socket, but it would be a little better to respect TMPDIR variable, maybe using std::filesystem::temp_directory_path() iike bitcoin tests


    enirox001 commented at 11:26 AM on July 8, 2026:

    Thanks, I changed this to use std::filesystem::temp_directory_path(), similar to the Bitcoin tests.

    I also noticed that bitcoin core uses its own fs header, which is a more elaborate implementation for safer windows path handling. We don’t have that in libmultiprocess, and since Windows will be supported soon, this might also be a good place to look at using the libkj IO helpers in a follow-up

  104. in test/mp/test/listen_tests.cpp:138 in ef3567904b
     133 | +              EventLoop loop("mptest-server", [this](mp::LogMessage log) {
     134 | +                  KJ_LOG(INFO, log.level, log.message);
     135 | +                  if (log.level == mp::Log::Raise) throw std::runtime_error(log.message);
     136 | +              });
     137 | +              loop.testing_hook_connected = [&] {
     138 | +                  std::lock_guard<std::mutex> lock(counter_mutex);
    


    ryanofsky commented at 3:19 PM on July 7, 2026:

    In commit "test: add dedicated ListenConnections coverage" (ef3567904b57b0c83fb59bedead1d88ed5d022bf)

    Would be a little better to use Mutex and Lock classes from util.h since they have thread safety annotations.

    Same comment applies to new locks and mutexes added in the next commit.

    (As possible followup it might be nice to have a linter that disallows non-annotated classes by default)


    enirox001 commented at 11:27 AM on July 8, 2026:

    Done. Switched the test counters to use the project Mutex and Lock wrappers instead of std::mutex and standard lock guards, and applied the same change to the locks added in the next commit.

  105. in test/mp/test/listen_tests.cpp:161 in ef3567904b
     156 | +    }
     157 | +
     158 | +    void WaitForConnectedCount(size_t expected_count)
     159 | +    {
     160 | +        std::unique_lock<std::mutex> lock(counter_mutex);
     161 | +        const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5);
    


    ryanofsky commented at 3:26 PM on July 7, 2026:

    In commit "test: add dedicated ListenConnections coverage" (ef3567904b57b0c83fb59bedead1d88ed5d022bf)

    Unexplained hardcoded timeouts in tests like this make code more difficult to understand and maintain. Would suggest defining constexpr auto FAILURE_TIMEOUT{30s}; similar to spawn_tests. The constant can then be used here and elsewhere for similar operations that are expected to complete quickly but will cause the test to fail if they do not.


    enirox001 commented at 11:27 AM on July 8, 2026:

    Noted, having these hardoced timeouts are detrimental to clarity and maintenace. Added a named FAILURE_TIMEOUT constant and reused it for the wait operations, matching the pattern in spawn_tests.

  106. in include/mp/proxy-io.h:884 in dcf9c38eb4
     884 | +    size_t m_active_connections{0};
     885 | +};
     886 | +
     887 |  template <typename InitInterface, typename InitImpl>
     888 | -void _Listen(EventLoop& loop, kj::Own<kj::ConnectionReceiver>&& listener, InitImpl& init)
     889 | +void Listener::listen(EventLoop& loop, InitImpl& init, const std::shared_ptr<Listener>& self)
    


    ryanofsky commented at 3:44 PM on July 7, 2026:

    In commit "test: add dedicated ListenConnections coverage" (ef3567904b57b0c83fb59bedead1d88ed5d022bf)

    I think it's a little confusing and creates an opportunity for bugs that a non-static method is taking a self parameter, and could potentially be called with two different Listener instances.

    Also, if we want to start returning Listener objects from ListenConnections to applications to stop listening, it would be confusing for this method to be part of the public interface, since applications should never call it.

    For both of these reasons would suggest dropping the Listen::listener method, and just keeping the previous _Listen function which is only meant to be used internally and not called by applications. This would also be a code simplification

    Suggested change:

    <details><summary>diff</summary> <p>

    --- a/include/mp/proxy-io.h
    +++ b/include/mp/proxy-io.h
    @@ -870,32 +870,27 @@ struct Listener
             return m_max_connections && m_active_connections >= *m_max_connections;
         }
     
    -    //! Handle incoming connections by calling _Serve, to create ProxyServer
    -    //! objects and forward requests to the init object.
    -    template <typename InitInterface, typename InitImpl>
    -    void listen(EventLoop& loop, InitImpl& init, const std::shared_ptr<Listener>& self);
    -
         kj::Own<kj::ConnectionReceiver> m_receiver;
         std::optional<size_t> m_max_connections;
         size_t m_active_connections{0};
     };
     
     template <typename InitInterface, typename InitImpl>
    -void Listener::listen(EventLoop& loop, InitImpl& init, const std::shared_ptr<Listener>& self)
    +void _Listen(const std::shared_ptr<Listener>& listener, EventLoop& loop, InitImpl& init)
     {
    -    if (atCapacity()) return;
    +    if (listener->atCapacity()) return;
     
    -    auto* receiver = m_receiver.get();
    +    auto* receiver = listener->m_receiver.get();
         loop.m_task_set->add(receiver->accept().then(
    -        [&loop, &init, self](kj::Own<kj::AsyncIoStream>&& stream) {
    -            ++self->m_active_connections;
    -            _Serve<InitInterface>(loop, kj::mv(stream), init, [&loop, &init, self] {
    -                const bool resume_accept{self->atCapacity()};
    -                assert(self->m_active_connections > 0);
    -                --self->m_active_connections;
    -                if (resume_accept) self->listen<InitInterface>(loop, init, self);
    +        [&loop, &init, listener](kj::Own<kj::AsyncIoStream>&& stream) {
    +            ++listener->m_active_connections;
    +            _Serve<InitInterface>(loop, kj::mv(stream), init, [&loop, &init, listener] {
    +                const bool resume_accept{listener->atCapacity()};
    +                assert(listener->m_active_connections > 0);
    +                --listener->m_active_connections;
    +                if (resume_accept) _Listen<InitInterface>(listener, loop, init);
                 });
    -            self->listen<InitInterface>(loop, init, self);
    +            _Listen<InitInterface>(listener, loop, init);
             }));
     }
     
    @@ -920,7 +915,7 @@ void ListenConnections(EventLoop& loop, int fd, InitImpl& init, std::optional<si
             auto listener{std::make_shared<Listener>(
                 loop.m_io_context.lowLevelProvider->wrapListenSocketFd(fd, kj::LowLevelAsyncIoProvider::TAKE_OWNERSHIP),
                 max_connections)};
    -        listener->listen<InitInterface>(loop, init, listener);
    +        _Listen<InitInterface>(listener, loop, init);
         });
     }
     
    

    </p> </details>


    enirox001 commented at 11:27 AM on July 8, 2026:

    Done, changed this back to an internal _Listen() helper that takes the std::shared_ptr<Listener> directly

    I had originally made this a Listener::listen() method because the async callbacks needed to capture a shared_ptr<Listener> to keep the listener alive, but I was probably thinking about the ownership problem in the wrong shape.

    This approach keeps the same lifetime behavior without the confusing memberself parameter.

  107. in test/mp/test/listen_tests.cpp:131 in ef3567904b
     126 | +//! UnixListener socket, and records connection/disconnection counts through
     127 | +//! EventLoop test hooks
     128 | +class ListenSetup
     129 | +{
     130 | +public:
     131 | +    ListenSetup()
    


    ryanofsky commented at 3:46 PM on July 7, 2026:

    In commit "test: add dedicated ListenConnections coverage" (ef3567904b57b0c83fb59bedead1d88ed5d022bf)

    May want to declare this explicit now since it becomes explicit anyway in the next commit


    enirox001 commented at 11:28 AM on July 8, 2026:

    Done, marked as explicit in the test: add dedicated ListenConnections coverage commit

  108. in doc/versions.md:13 in 2f7f1bf614 outdated
       6 | @@ -7,9 +7,16 @@ Library versions are tracked with simple
       7 |  Versioning policy is described in the [version.h](../include/mp/version.h)
       8 |  include.
       9 |  
      10 | -## v11
      11 | +## v12
      12 |  - Current unstable version.
      13 |  
      14 | +## [v11.0](https://github.com/bitcoin-core/libmultiprocess/commits/v11.0)
    


    ryanofsky commented at 3:59 PM on July 7, 2026:

    In commit "doc/version: Bump version 11 > 12" (2f7f1bf61432c361310e1c9c5bba1602d9cdf066)

    These updates look right but might better to use the more complete list of changes from https://github.com/bitcoin/bitcoin/pull/35661. Suggested diff is below, but also feel free to keep current test or just replace the list with a TBD comment.

    <details><summary>diff</summary> <p>

    --- a/doc/versions.md
    +++ b/doc/versions.md
    @@ -14,11 +14,17 @@ include.
       and resume accepting after existing connections disconnect.
     
     ## [v11.0](https://github.com/bitcoin-core/libmultiprocess/commits/v11.0)
    -- Tolerates unexpected exceptions in event loop `post()` callbacks.
    -- Tolerates exceptions from remote destroy during cleanup in `ProxyClient`.
    -- Supports primitive `std::optional` struct fields in the code generator (`mpgen`).
    -- Adds `TypeName()` and improves debug log coverage for Proxy object lifecycle.
    -- Updates build compatibility with recent Nix and CMake versions.
    +- Adds `makePool` method on `ThreadMap` to support thread pool routing, allowing requests without a specific client thread to be dispatched to a pool using a shortest-queue strategy ([#283](https://github.com/bitcoin-core/libmultiprocess/pull/283)).
    +- Adds `std::unordered_set` support, a `BuildList` helper, and a `ReadList` helper to reduce duplication in list build and read handlers ([#277](https://github.com/bitcoin-core/libmultiprocess/pull/277), [#285](https://github.com/bitcoin-core/libmultiprocess/pull/285)).
    +- Adds support for translating C++ `std::optional<T>` struct fields to pairs of `T` + `hasT :Bool` Cap'n Proto struct fields, allowing unset optional primitive fields to be represented ([#243](https://github.com/bitcoin-core/libmultiprocess/pull/243)).
    +- Produces more readable log output for Proxy object lifecycle events and IPC server-side failures ([#218](https://github.com/bitcoin-core/libmultiprocess/pull/218)).
    +- Handles exceptions thrown by `destroy` methods by logging instead of aborting ([#273](https://github.com/bitcoin-core/libmultiprocess/pull/273)). This can prevent server crashes when non-libmultiprocess clients disconnect without destroying objects, in the case where a server object owns client objects and the server destructor tries to call the disconnected client to free them ([#219](https://github.com/bitcoin-core/libmultiprocess/issues/219)).
    +- Handles unexpected exceptions thrown by callbacks (that should never happen) by logging errors instead of deadlocking ([#260](https://github.com/bitcoin-core/libmultiprocess/pull/260)).
    +- Fixes a rare mptest hang on musl builds caused by a lost wakeup bug in `Waiter` ([#295](https://github.com/bitcoin-core/libmultiprocess/pull/295)).
    +- Fixes a race condition in a log print detected by TSan ([#286](https://github.com/bitcoin-core/libmultiprocess/pull/286)).
    +- Build improvements: makes `target_capnp_sources` work correctly when libmultiprocess is used as a CMake subproject ([#289](https://github.com/bitcoin-core/libmultiprocess/pull/289)), adds `mp_headers` target for better lint tool support ([#291](https://github.com/bitcoin-core/libmultiprocess/pull/291)), and fixes compatibility with recent Nix and CMake 4.0 ([#238](https://github.com/bitcoin-core/libmultiprocess/pull/238)).
    +- Test, CI, documentation, and minor code improvements: design document corrections ([#278](https://github.com/bitcoin-core/libmultiprocess/pull/278)), field constant comments ([#279](https://github.com/bitcoin-core/libmultiprocess/pull/279)), clang-tidy fix ([#292](https://github.com/bitcoin-core/libmultiprocess/pull/292)), new smoke test for double-precision float values ([#294](https://github.com/bitcoin-core/libmultiprocess/pull/294)), new test for recursive async IPC calls ([#301](https://github.com/bitcoin-core/libmultiprocess/pull/301)), removal of libevent from Core CI builds ([#299](https://github.com/bitcoin-core/libmultiprocess/pull/299)), and rename of `EventLoop::m_num_clients` to `m_num_refs` ([#302](https://github.com/bitcoin-core/libmultiprocess/pull/302)).
    +- Used in Bitcoin Core master branch, pulled in by [#35661](https://github.com/bitcoin/bitcoin/pull/35661).
     
     ## [v10.0](https://github.com/bitcoin-core/libmultiprocess/commits/v10.0)
     - Increases spawn test timeout to avoid spurious failures.
    

    </p> </details>


    enirox001 commented at 11:28 AM on July 8, 2026:

    Thanks, I replaced the shorter v11 list with the more complete notes from bitcoin/bitcoin#35661. That seems better than leaving a partial summary here

  109. in test/mp/test/listen_tests.cpp:242 in dcf9c38eb4 outdated
     237 | +
     238 | +    client2.reset();
     239 | +    server.WaitForDisconnectedCount(2);
     240 | +
     241 | +    auto client3 = std::make_unique<ClientSetup>(server.listener.MakeConnectedSocket());
     242 | +    server.WaitForConnectedCount(3);
    


    ryanofsky commented at 4:08 PM on July 7, 2026:

    In commit "proxy: add local connection limit to ListenConnections()" (dcf9c38eb41ee801f2842dfa35986ef8335ffdba)

    I think it would make sense before each WaitFor{Connected,Disconnected}Count call to have a KJ_EXPECT call checking the previous value. Should make the test stronger and also clearer and easier to debug if something is wrong

    <details><summary>diff</summary> <p>

    --- a/test/mp/test/listen_tests.cpp
    +++ b/test/mp/test/listen_tests.cpp
    @@ -168,6 +168,12 @@ public:
             return connected_count;
         }
     
    +    size_t DisconnectedCount()
    +    {
    +        std::lock_guard<std::mutex> lock(counter_mutex);
    +        return disconnected_count;
    +    }
    +
         void WaitForConnectedCount(size_t expected_count)
         {
             std::unique_lock<std::mutex> lock(counter_mutex);
    @@ -203,8 +209,8 @@ public:
     KJ_TEST("ListenConnections accepts incoming connections")
     {
         ListenSetup server;
    +    KJ_EXPECT(server.ConnectedCount() == 0);
         auto client = std::make_unique<ClientSetup>(server.listener.MakeConnectedSocket());
    -
         server.WaitForConnectedCount(1);
         KJ_EXPECT(client->client->add(1, 2) == 3);
     }
    @@ -217,29 +223,34 @@ KJ_TEST("ListenConnections enforces a local connection limit")
     
         ListenSetup server(/*max_connections=*/1);
     
    +    KJ_EXPECT(server.ConnectedCount() == 0);
         auto client1 = std::make_unique<ClientSetup>(server.listener.MakeConnectedSocket());
         server.WaitForConnectedCount(1);
    +
         KJ_EXPECT(client1->client->add(1, 2) == 3);
     
         auto client2 = std::make_unique<ClientSetup>(server.listener.MakeConnectedSocket());
    -
         // Without this sync, ConnectedCount() == 2 might pass even if
         // max_connections was not enforced because the event loop has not accepted
         // client3 yet.
         (**server.m_loop_ref).sync([] {});
    -    KJ_EXPECT(server.ConnectedCount() == 1);
     
    +    KJ_EXPECT(server.ConnectedCount() == 1);
    +    KJ_EXPECT(server.DisconnectedCount() == 0);
         client1.reset();
         server.WaitForDisconnectedCount(1);
         server.WaitForConnectedCount(2);
     
         KJ_EXPECT(client2->client->add(2, 3) == 5);
     
    +    KJ_EXPECT(server.DisconnectedCount() == 1);
         client2.reset();
         server.WaitForDisconnectedCount(2);
     
    +    KJ_EXPECT(server.ConnectedCount() == 2);
         auto client3 = std::make_unique<ClientSetup>(server.listener.MakeConnectedSocket());
         server.WaitForConnectedCount(3);
    +
         KJ_EXPECT(client3->client->add(3, 4) == 7);
     }
     
    @@ -250,22 +261,22 @@ KJ_TEST("ListenConnections accepts multiple connections")
         
         ListenSetup server(/*max_connections=*/2);
     
    +    KJ_EXPECT(server.ConnectedCount() == 0);
         auto client1 = std::make_unique<ClientSetup>(server.listener.MakeConnectedSocket());
         auto client2 = std::make_unique<ClientSetup>(server.listener.MakeConnectedSocket());
    -
         server.WaitForConnectedCount(2);
     
         KJ_EXPECT(client1->client->add(1, 2) == 3);
         KJ_EXPECT(client2->client->add(2, 3) == 5);
     
         auto client3 = std::make_unique<ClientSetup>(server.listener.MakeConnectedSocket());
    -
         // Without this sync, ConnectedCount() == 2 might pass even if
         // max_connections was not enforced because the event loop has not accepted
         // client3 yet.
         (**server.m_loop_ref).sync([] {});
    -    KJ_EXPECT(server.ConnectedCount() == 2);
     
    +    KJ_EXPECT(server.ConnectedCount() == 2);
    +    KJ_EXPECT(server.DisconnectedCount() == 0);
         client1.reset();
         server.WaitForDisconnectedCount(1);
         server.WaitForConnectedCount(3);
    

    </p> </details>


    enirox001 commented at 11:28 AM on July 8, 2026:

    Done. added this helper as well as included in the relevant tests

  110. ryanofsky approved
  111. ryanofsky commented at 4:31 PM on July 7, 2026: collaborator

    Code review ACK dcf9c38eb41ee801f2842dfa35986ef8335ffdba.

    This looks good in it's current form and could be merged. Let me know if you'd prefer that or want to make another round of updates. I'm also planning to open a new bitcoin core PR bumping the subtree and incorporating this change after this PR is merged

  112. enirox001 commented at 8:03 AM on July 8, 2026: contributor

    This looks good in it's current form and could be merged. Let me know if you'd prefer that or want to make another round of updates

    Thanks for giving this a look, i intend to make another round of updates. Should be able to have that done soon

  113. doc/version: Bump version 11 > 12
    This bumps the major version to 12 because upcoming commits
    introduce a non-trivial feature by adding a local max-connections
    parameter to the ListenConnections() method
    
    This also records release notes for v11 in doc/version.md. These notes
    are unrelated to this PR and describe changes that will be tagges before
    this PR
    033f812195
  114. enirox001 force-pushed on Jul 8, 2026
  115. enirox001 force-pushed on Jul 8, 2026
  116. enirox001 force-pushed on Jul 8, 2026
  117. enirox001 commented at 11:53 AM on July 8, 2026: contributor

    Thanks for the reviews @ryanofsky @Eunovo. Addressed them in the latest commits

    I'm also planning to open a new bitcoin core PR bumping the subtree and incorporating this change after this PR is merged

    This would be helpful to https://github.com/bitcoin/bitcoin/pull/35037 as well as greatly simplify the work needed for the PR. Looking forward to reviewing this

  118. enirox001 force-pushed on Jul 8, 2026
  119. enirox001 force-pushed on Jul 8, 2026
  120. test: add dedicated ListenConnections coverage
    Add a separate listen_tests.cpp file with reusable UnixListener, ClientSetup
    and ListenSetup helpers for exercising ListenConnections() with real Unix
    domain sockets.
    
    The new test covers the baseline behavior that ListenConnections() accepts an
    incoming connection and serves requests over it. Keeping this coverage separate
    from the existing general proxy tests makes the socket listener setup easier to
    review and provides a clearer place to extend listener-specific behavior in
    follow-up commits.
    43172f52d9
  121. in test/mp/test/listen_tests.cpp:266 in 95aaf9ef46 outdated
     261 | +
     262 | +KJ_TEST("ListenConnections accepts multiple connections")
     263 | +{
     264 | +    // With max-connections=2, two clients should be accepted and usable at the
     265 | +    // same time, while a third waits until one active client disconnects.
     266 | +    
    


    ryanofsky commented at 12:17 PM on July 8, 2026:

    In commit "proxy: add local connection limit to ListenConnections()" (95aaf9ef467584e41e1f6ea9cef00b90d0cb075e)

    Note: there is trailing whitespace on this line. Also I think it would be good to group together the ConnectedCount() / WaitForConnectedCount() calls in the test, and offset the client->add calls which are not directly related to the connection counts for readability. These changes are not important at all so feel free to ignore, but would suggest

    <details><summary>diff</summary> <p>

    --- a/test/mp/test/listen_tests.cpp
    +++ b/test/mp/test/listen_tests.cpp
    @@ -232,10 +232,10 @@ KJ_TEST("ListenConnections enforces a local connection limit")
         KJ_EXPECT(server.ConnectedCount() == 0)
         auto client1 = std::make_unique<ClientSetup>(server.listener.MakeConnectedSocket());
         server.WaitForConnectedCount(1);
    +
         KJ_EXPECT(client1->client->add(1, 2) == 3);
     
         auto client2 = std::make_unique<ClientSetup>(server.listener.MakeConnectedSocket());
    -
         // Without this sync, ConnectedCount() == 1 might pass even if
         // max_connections was not enforced because the event loop has not accepted
         // client2 yet.
    @@ -263,20 +263,18 @@ KJ_TEST("ListenConnections accepts multiple connections")
     {
         // With max-connections=2, two clients should be accepted and usable at the
         // same time, while a third waits until one active client disconnects.
    -    
    +
         ListenSetup server(/*max_connections=*/2);
     
         KJ_EXPECT(server.ConnectedCount() == 0);
         auto client1 = std::make_unique<ClientSetup>(server.listener.MakeConnectedSocket());
         auto client2 = std::make_unique<ClientSetup>(server.listener.MakeConnectedSocket());
    -
         server.WaitForConnectedCount(2);
     
         KJ_EXPECT(client1->client->add(1, 2) == 3);
         KJ_EXPECT(client2->client->add(2, 3) == 5);
     
         auto client3 = std::make_unique<ClientSetup>(server.listener.MakeConnectedSocket());
    -
         // Without this sync, ConnectedCount() == 2 might pass even if
         // max_connections was not enforced because the event loop has not accepted
         // client3 yet.
    

    </p> </details>


    enirox001 commented at 12:56 PM on July 8, 2026:

    These are nice to have. included in the recent changes

  122. ryanofsky approved
  123. ryanofsky commented at 12:21 PM on July 8, 2026: collaborator

    Code review ACK f9ef92e9b22775dbe6f33c6a0b693bb305356896. Thanks for the updates!Feel free to ignore my comment about whitespace. I only posted it since I noticed CI was failing and thought there would have to be another push

  124. DrahtBot requested review from Eunovo on Jul 8, 2026
  125. enirox001 force-pushed on Jul 8, 2026
  126. proxy: add local connection limit to ListenConnections()
    Add an optional max_connections parameter to ListenConnections() and track
    the limit with listener-local active connection state, so accepting pauses
    at capacity and resumes after a disconnect.
    
    Update listener tests for cap enforcement, resume behavior, and multiple
    active connections.
    39a10ce895
  127. enirox001 force-pushed on Jul 8, 2026
  128. enirox001 commented at 12:58 PM on July 8, 2026: contributor

    Thanks for the updates!Feel free to ignore my comment about whitespace. I only posted it since I noticed CI was failing and thought there would have to be another push

    Addressed these in the recent change. Faced some IWYU errors after the comment above. So had to resolve them anyway. Thanks

  129. ryanofsky approved
  130. ryanofsky commented at 3:24 PM on July 8, 2026: collaborator

    Code review ACK 39a10ce8958ef9d49d0ec82acd3a6d506d5c8fee

    Just include and whitespace changes since last review

  131. ryanofsky merged this on Jul 8, 2026
  132. ryanofsky closed this on Jul 8, 2026

  133. xyzconstant commented at 12:20 AM on July 9, 2026: contributor

    Post-merge re-ACK 39a10ce8958ef9d49d0ec82acd3a6d506d5c8fee


github-metadata-mirror

This is a metadata mirror of the GitHub repository bitcoin-core/libmultiprocess. This site is not affiliated with GitHub. Content is generated from a GitHub metadata backup.
generated: 2026-07-14 21:30 UTC

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