proxy-io.h: Add Connection `disconnect` and `waitDrained` methods #335

pull ryanofsky wants to merge 8 commits into bitcoin-core:master from ryanofsky:pr/keepconn changing 9 files +580 −88
  1. ryanofsky commented at 2:54 PM on August 7, 2026: collaborator

    Note: This is based on #361. Initial commits should be reviewed in that PR.


    Add Connection class disconnect and waitDrained methods to provide more flexibility when forcibly disconnecting from remote clients or servers.

    Without these methods, the only way to forcibly close IPC connections is to delete Connection objects. This works but is not ideal because once a Connection object is gone, it is difficult to track state still associated with the connection, particularly:

    • ProxyServer objects that may still be alive because they are executing asynchronous requests made before the disconnect. Without a way to track these objects, there is no generic way to wait for requests to finish existing after disconnecting. So individual IPC interfaces like the Bitcoin mining interface would need to implement custom synchronization to avoid race conditions during shutdown. Followup PR https://github.com/bitcoin/bitcoin/pull/35932 builds on this PR, calling the new waitDrained method introduced here to avoid IPC mining crashes on Bitcoin core shutdown without needing to change the mining code. A unit test is added here simulating these mining crashes.

    • ProxyClient objects that contain pointers to Connection objects. Currently ProxyClient object need to register cleanup handlers with Connection objects to deal with Connections being deleted, which consumes memory and complicates ProxyClient shutdown logic. After this change, a followup PR will drop the cleanup handlers so Connection objects no longer need to track lists of ProxyClient objects associated with them. This is implemented in #336.

  2. DrahtBot commented at 2:54 PM on August 7, 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
    Concept ACK xyzconstant

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

    <!--174a7506f384e20aa4161008e828411d-->

    Conflicts

    Reviewers, this pull request conflicts with the following ones:

    • #365 (proxy-io: Generalize ConnectStream / ServeStream for use in tests by ryanofsky)
    • #361 (proxy-io: Fix theoretical disconnect bugs by ryanofsky)

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

    <!--5faf32d7da4f0f540f40219e4f7537a3-->

  3. xyzconstant commented at 3:28 PM on August 7, 2026: contributor

    Concept ACK

  4. enirox001 commented at 9:23 AM on August 11, 2026: contributor

    CI seems upset?

    /home/runner/work/libmultiprocess/libmultiprocess/src/mp/proxy.cpp should add these lines:
    #include <capnp/rpc-twoparty.h>  // for TwoPartyVatNetwork
    
  5. ryanofsky force-pushed on Aug 13, 2026
  6. ryanofsky commented at 2:57 AM on August 13, 2026: collaborator

    <!-- begin push-2 -->

    Updated 39ed2ca8f068a065403afa89bbf1d14f75773720 -> a40189f5bbed89f09e5d63ce8377dc90574983f6 (pr/keepconn.1 -> pr/keepconn.2, compare)<!-- end --> fixing iwyu errors and olddeps failure due to incompatibility with old capnproto versions which lack kj:::TaskSet::clear method https://github.com/bitcoin-core/libmultiprocess/actions/runs/31189931644/job/92903876132?pr=335

    <!-- begin push-3 -->

    Added 1 commits a40189f5bbed89f09e5d63ce8377dc90574983f6 -> 11929f1b9b39aba8ac636e66bd727689bb2b7a95 (pr/keepconn.2 -> pr/keepconn.3, compare)<!-- end --> to fix pre-existing ~ThreadContext() bug exposed by combination of new test in this PR and the onDisconnect handler added in #298 commit bb473690c97ceed78a482d6a97b480cec4a63190 https://github.com/bitcoin-core/libmultiprocess/actions/runs/31662352370/job/94329590873?pr=335

    <!-- begin push-4 -->

    Updated 11929f1b9b39aba8ac636e66bd727689bb2b7a95 -> 901a090da03ed6b04d46a4040ed16fdeb238e7fd (pr/keepconn.3 -> pr/keepconn.4, compare)<!-- end --> to fix iwyu error https://github.com/bitcoin-core/libmultiprocess/actions/runs/31666872134/job/94343269972?pr=335

  7. ryanofsky force-pushed on Aug 13, 2026
  8. in src/mp/proxy.cpp:125 in 39cc757fba
     120 | +void Connection::disconnect()
     121 | +{
     122 | +    // Disconnecting triggers I/O and tears down capnp state, so it must run on
     123 | +    // the event loop thread, like the destructor.
     124 | +    assert(std::this_thread::get_id() == m_loop->m_thread_id);
     125 | +    if (m_disconnected) return;
    


    enirox001 commented at 1:02 PM on August 20, 2026:

    In commit https://github.com/bitcoin-core/libmultiprocess/pull/335/changes/39cc757fba71871b154d915b02cf3967fe73fe0d: ipc: add Connection::disconnect() separating teardown from destruction

    disconnect() sets m_disconnected = true; later on we clean everything up, so I am unsure, but if there was a scenario where one of the cleanups threw, it would not complete the rest. This might not be a problem, but another call to disconnect() would be a no-op.

    I do not think all the operations after this can cause this to throw and lead to this, but shutdownWrite() might if it throws an exception other than the ones mentioned.

    A simple fix is to set the m_disconnected = true only after all teardown that must run has completed.

    index 0aaa58a..8b9f458 100644
    --- a/src/mp/proxy.cpp
    +++ b/src/mp/proxy.cpp
    @@ -124,7 +124,6 @@ void Connection::disconnect()
         // the event loop thread, like the destructor.
         assert(std::this_thread::get_id() == m_loop->m_thread_id);
         if (m_disconnected) return;
    -    m_disconnected = true;
    
         // Cancel pending onDisconnect handlers first. Severing the connection
         // below completes m_network.onDisconnect() promises, and the registered
    @@ -253,6 +252,8 @@ void Connection::disconnect()
         // stream.
         m_network.reset();
         m_stream = nullptr;
    +
    +    m_disconnected = true;
     }
    
     void Connection::waitDrained()
    

    or a better solution that make sure the the cleanup happens even if shutdownWrite fails?


    ryanofsky commented at 9:04 PM on August 20, 2026:

    re: #335 (review)

    I think I want to drop the m_disconnected variable and just treat m_network being nullopt the same as m_disconnected being true, which I think should be equivalent to your suggestions.

    It's also true that cleanup functions throwing is not something that this library handles very well generally, and could handle better in many cases.


    ryanofsky commented at 7:56 PM on September 4, 2026:

    re: #335 (review)

    I think I want to drop the m_disconnected variable and just treat m_network being nullopt the same as m_disconnected being true, which I think should be equivalent to your suggestions.

    I did drop this extra variable in latest push, but didn't look into the exception safety yet. As mentioned previously there are many other places in the library where unexpected exceptions from callbacks will cause problems. I do want revisit and see if there's an improvement that can be made here but would want to keep scope limited and not get into fixing preexisting problems because that could really increase the size of this change.

  9. in include/mp/proxy-io.h:489 in 39cc757fba
     485 | @@ -473,7 +486,7 @@ class Connection
     486 |          // handler fires, do not call the function f right away, instead add it
     487 |          // to the EventLoop TaskSet to avoid "Promise callback destroyed itself"
     488 |          // error in the typical case where f deletes this Connection object.
     489 | -        m_on_disconnect.add(m_network.onDisconnect().then(
     490 | +        m_on_disconnect->add(m_network->onDisconnect().then(
    


    enirox001 commented at 1:09 PM on August 20, 2026:

    In https://github.com/bitcoin-core/libmultiprocess/pull/335/changes/39cc757fba71871b154d915b02cf3967fe73fe0d: ipc: add Connection::disconnect() separating teardown from destruction

    Before this PR, when a remote side disconnected, libmultiprocess had callbacks that would eventually remove the Connection. It does not necessarily call the remove operation immediately; it can schedule it into another task set. The new disconnect() wants different behaviour. such that it will disconnect and then call waitDrained later on. So it tries to reset the m_on_disconnect callbacks.

    But if the callback has already progressed one step further before reset happens, this violates the goal of this new system.

    In https://github.com/bitcoin-core/libmultiprocess/pull/336/changes/aa49a11c028f92aafa3867a59f9eb52cf9bdde6d this is made to use a weak_ptr, but I wonder if we should move those changes to this pr instead? Or rather, a small cancellation guard could be added to this PR such that it keeps the existing changes focused while preventing the potential regression.

    A minimal change adding a weak cancelation token that has moved into the event loop queue.

    index 1f77b26..d817eb6 100644
    --- a/include/mp/proxy-io.h
    +++ b/include/mp/proxy-io.h
    @@ -576,8 +576,18 @@ public:
             // handler fires, do not call the function f right away, instead add it
             // to the EventLoop TaskSet to avoid "Promise callback destroyed itself"
             // error in the typical case where f deletes this Connection object.
    +        const std::weak_ptr<void> guard{m_on_disconnect_guard};
             m_on_disconnect->add(m_network->onDisconnect().then(
    -            [f = std::forward<F>(f), this]() mutable { m_loop->m_task_set->add(kj::evalLater(kj::mv(f))); }));
    +            [f = std::forward<F>(f), guard, this]() mutable {
    +                m_loop->m_task_set->add(kj::evalLater(
    +                    [f = kj::mv(f), guard]() mutable {
    +                        // The connection-owned TaskSet may have already handed
    +                        // this callback to the event-loop TaskSet by the time
    +                        // disconnect() cancels it. Only run it if the
    +                        // connection has not been disconnected in between.
    +                        if (guard.lock()) f();
    +                    }));
    +            }));
         }
    
         EventLoopRef m_loop;
    @@ -587,6 +597,10 @@ public:
         //! disconnections, if the connection is closed locally first by deleting
         //! this Connection object.
         std::optional<kj::TaskSet> m_on_disconnect{std::in_place, m_error_handler};
    +    //! Lifetime token checked by onDisconnect handlers after they are handed
    +    //! off to the EventLoop TaskSet. Reset by disconnect() so a handler already
    +    //! queued there cannot run after local teardown.
    +    std::shared_ptr<void> m_on_disconnect_guard{std::make_shared<char>()};
         //! Wrapped in std::optional so disconnect() can destroy it (and m_stream
         //! below) to sever the transport while this object stays alive. Closing
         //! the stream is what makes the peer observe the disconnect: it reads EOF
    diff --git a/src/mp/proxy.cpp b/src/mp/proxy.cpp
    index 0aaa58a..06063f8 100644
    --- a/src/mp/proxy.cpp
    +++ b/src/mp/proxy.cpp
    @@ -133,6 +133,7 @@ void Connection::disconnect()
         // harmful when disconnect() is called separately by code that keeps using
         // the object afterwards (e.g. code waiting for in-flight calls to finish
         // before destroying it).
    +    m_on_disconnect_guard.reset();
         m_on_disconnect.reset();
    
         // Try to cancel any calls that may be executing.
    

    This change closes the gap where resetting m_on_disconnect was too late because the callback had already moved into the EventLoop task set


    enirox001 commented at 1:14 PM on August 20, 2026:

    In https://github.com/bitcoin-core/libmultiprocess/pull/335/changes/39cc757fba71871b154d915b02cf3967fe73fe0d: ipc: add Connection::disconnect() separating teardown from destruction

    The listener now keeps a counter of the active connections added in https://github.com/bitcoin-core/libmultiprocess/pull/269/changes/39a10ce8958ef9d49d0ec82acd3a6d506d5c8fee. When it is full, it stops accepting new connections, and when a client disconnects, a callback decreases the counter, and the listener can start accepting again.

    But when the server calls disconnect() it cancels that callback. The connection closes, but the counter does not change, so the listener might think it is full and never accept another connection

    Added this change to so that the listener count can be updated for every disconnect, while automatic deletion happens only for remote disconnects

    index 1f77b26..30627ec 100644
    --- a/include/mp/proxy-io.h
    +++ b/include/mp/proxy-io.h
    @@ -1016,10 +1016,12 @@ void _Serve(EventLoop& loop, kj::Own<kj::AsyncIoStream>&& stream, InitImpl& init
         auto it = loop.m_incoming_connections.begin();
         MP_LOG(loop, Log::Info) << "IPC server: socket connected.";
         if (loop.testing_hook_connected) loop.testing_hook_connected();
    -    it->onDisconnect([&loop, it, on_disconnect = std::forward<OnDisconnect>(on_disconnect)]() mutable {
    +    it->addSyncCleanup([on_disconnect = std::forward<OnDisconnect>(on_disconnect)]() mutable {
    +        on_disconnect();
    +    });
    +    it->onDisconnect([&loop, it]() mutable {
             MP_LOG(loop, Log::Info) << "IPC server: socket disconnected.";
             loop.m_incoming_connections.erase(it);
    -        on_disconnect();
             if (loop.testing_hook_disconnected) loop.testing_hook_disconnected();
         });
     }
    

    This test could also be added to verify the above behaviour

    index a9d4dca..240af3f 100644
    --- a/test/mp/test/listen_tests.cpp
    +++ b/test/mp/test/listen_tests.cpp
    @@ -265,6 +265,29 @@ KJ_TEST("ListenConnections enforces a local connection limit")
         KJ_EXPECT(client3->client->add(3, 4) == 7);
     }
    
    +KJ_TEST("ListenConnections resumes after a local disconnect")
    +{
    +    ListenSetup server(/*max_connections=*/1);
    +
    +    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());
    +    (**server.m_loop_ref).sync([] {});
    +    KJ_EXPECT(server.ConnectedCount() == 1);
    +
    +    EventLoop& loop{**server.m_loop_ref};
    +    loop.sync([&] {
    +        KJ_REQUIRE(loop.m_incoming_connections.size() == 1);
    +        loop.m_incoming_connections.front().disconnect();
    +        loop.m_incoming_connections.pop_front();
    +    });
    +
    +    server.WaitForConnectedCount(2);
    +    KJ_EXPECT(client2->client->add(2, 3) == 5);
    +}
    +
     KJ_TEST("ListenConnections accepts multiple connections")
     {
         // With max-connections=2, two clients should be accepted and usable at the
    

    ryanofsky commented at 9:17 PM on August 20, 2026:

    re: #335 (review)

    Hmm, this is an interesting finding. But if there is a bug here, it seems like a pre-existing one, not something caused by this change or made worse by it.

    You're saying if a remote disconnect happens first, and the m_network->onDisconnect() callback executes, but the kj::evalLater callback inside it does not execute yet, and if within that interval, the local process decided to delete the connection, then the connection could be deleted twice.

    This does seem like it might be possible, and I'd want to look into it a little more and write a test. I'd still be inclined to save a fix for a different PR, and I believe as you pointed out #336 might fix this.


    ryanofsky commented at 9:21 PM on August 20, 2026:

    re: #335 (review)

    Good catch and nice test!


    enirox001 commented at 10:27 AM on August 21, 2026:

    This is not exactly the point I intended to pass across; it was more

    • remote callback queued
    • local code calls disconnect(), intending to keep the connection alive
    • local code retains the connection pointer for waitDrained
    • queued callback erases and destroys the Connection
    • shutdown code uses the dangling connection pointer

    It is not a

    • local code deletes the connections
    • queued callback deletes it again

    But I think it could be possible for the connection object to be deleted twice, once by the object and another time by a queued callback (which is similar to the original concern i had) and yes, this would be a pre-existing issue

    I added a test to verify this (to an extent). First of all, I added a hook to be called before an onDisconnect callback is queued on the event loop

    index 1f77b26..100bd10 100644
    --- a/include/mp/proxy-io.h
    +++ b/include/mp/proxy-io.h
    @@ -378,6 +378,9 @@ public:
    
         //! Hook called on the event loop thread when a client has disconnected.
         std::function<void()> testing_hook_disconnected;
    +
    +    //! Hook called before an onDisconnect callback is queued on the event loop.
    +    std::function<void()> testing_hook_before_on_disconnect_queued;
     };
    
     //! Single element task queue used to handle recursive capnp calls. (If the
    @@ -577,7 +580,12 @@ public:
             // to the EventLoop TaskSet to avoid "Promise callback destroyed itself"
             // error in the typical case where f deletes this Connection object.
             m_on_disconnect->add(m_network->onDisconnect().then(
    -            [f = std::forward<F>(f), this]() mutable { m_loop->m_task_set->add(kj::evalLater(kj::mv(f))); }));
    +            [f = std::forward<F>(f), this]() mutable {
    +                if (m_loop->testing_hook_before_on_disconnect_queued) {
    +                    m_loop->testing_hook_before_on_disconnect_queued();
    +                }
    +                m_loop->m_task_set->add(kj::evalLater(kj::mv(f)));
    +            }));
         }
    
         EventLoopRef m_loop;
    

    and then wrote a test that cancels an already queued onDisconnect callback

    index 5bccb86..4fd906c 100644
    --- a/test/mp/test/test.cpp
    +++ b/test/mp/test/test.cpp
    @@ -291,6 +291,42 @@ KJ_TEST("Calling IPC method after server connection is closed")
         EXPECT_EXCEPTION(foo->add(1, 2), "IPC client method call interrupted by disconnect.");
     }
    
    +KJ_TEST("Destroying a connection cancels an already queued onDisconnect callback")
    +{
    +    std::promise<bool> result;
    +    std::thread loop_thread{[&] {
    +        EventLoop loop("mptest", [](mp::LogMessage) {});
    +        auto pipe = loop.m_io_context.provider->newTwoWayPipe();
    +        auto server_connection =
    +            std::make_unique<Connection>(loop, kj::mv(pipe.ends[0]), [&](Connection& connection) {
    +                return capnp::Capability::Client(kj::heap<ProxyServer<messages::FooInterface>>(
    +                    std::make_shared<FooImplementation>(), connection));
    +            });
    +        auto client_connection = std::make_unique<Connection>(loop, kj::mv(pipe.ends[1]));
    +        auto client = client_connection->m_rpc_system->bootstrap(ServerVatId().vat_id).castAs<messages::FooInterface>();
    +        bool callback_ran{false};
    +
    +        server_connection->onDisconnect([&] { callback_ran = true; });
    +        loop.testing_hook_before_on_disconnect_queued = [&] {
    +            loop.m_task_set->add(kj::evalLater([&] {
    +                server_connection.reset();
    +                loop.m_task_set->add(kj::evalLater([&] {
    +                    client = nullptr;
    +                    client_connection.reset();
    +                    result.set_value(callback_ran);
    +                }));
    +            }));
    +        };
    +
    +        loop.m_task_set->add(kj::evalLater([&] { client_connection->disconnect(); }));
    +        loop.loop();
    +    }};
    +
    +    const bool callback_ran{result.get_future().get()};
    +    loop_thread.join();
    +    KJ_EXPECT(!callback_ran);
    +}
    +
     KJ_TEST("Calling IPC method and disconnecting during the call")
     {
         TestSetup setup{/*client_owns_connection=*/false}
    

    This test fails

    This shows that the onDisconnect callback can execute after its owning Connection has already been destroyed, and if this happens to try to delete the Connection object, it could lead to undefined behavior


    ryanofsky commented at 7:12 PM on September 4, 2026:

    re: #335 (review)

    Added this change to so that the listener count can be updated for every disconnect, while automatic deletion happens only for remote disconnects

    Thanks for the bug report, and fix, and test. This is a separate, preexisting bug so I made a new PR #361 to address it. Your changes are in bb21177965e363b32878f8258cc2bc1f9a8661f4 there. This bug isn't a practical problem for bitcoin core because it does not disconnect IPC clients except when it is shutting down. But it could a problem for other IPC servers using this code. The problem is also not new to this PR. Even though this PR is adding a disconnect method which makes it possible to disconnect clients without deleting the Connection objects, it was always possible to disconnect clients by deleting the Connection objects.


    ryanofsky commented at 8:05 PM on September 4, 2026:

    re: #335 (review)

    • shutdown code uses the dangling connection pointer

    Thanks yes that makes sense. I was assuming the only thing shutdown code would be doing with the pointer would be deleting it. But of course if it did something else with the pointer (like call waitDrained) the symptom of the bug would be a use-after-free and not a double delete.

    Either way, this is a preexisting bug, so I adopted your test and fix and used them in #361 commit bc98767dadb4d67b7cb6c87ca86696a7f888d715.

  10. in include/mp/proxy-io.h:445 in 39cc757fba outdated
     441 | @@ -442,22 +442,35 @@ class Connection
     442 |  public:
     443 |      Connection(EventLoop& loop, kj::Own<kj::AsyncIoStream>&& stream_)
     444 |          : m_loop(loop), m_stream(kj::mv(stream_)),
     445 | -          m_network(*m_stream, ::capnp::rpc::twoparty::Side::CLIENT, ::capnp::ReaderOptions()),
     446 | -          m_rpc_system(::capnp::makeRpcClient(m_network)) {}
     447 | +          m_network(std::in_place, *m_stream, ::capnp::rpc::twoparty::Side::CLIENT, ::capnp::ReaderOptions()),
    


    enirox001 commented at 1:17 PM on August 20, 2026:

    In commit https://github.com/bitcoin-core/libmultiprocess/pull/335/changes/39cc757fba71871b154d915b02cf3967fe73fe0d: ipc: add Connection::disconnect() separating teardown from destruction

    Connection can now remain alive after it has been disconnected, but the class does not define which methods are safe to call afterwards.

    Previously, disconnection meant destroying the whole object, but now the connection object still exists. This is needed so callers can use methods such as waitDrained, but other methods still behave as if the connection is active.

    Could some documentation, assertion, or runtime check be helpful for this?


    ryanofsky commented at 7:14 PM on September 4, 2026:

    re: #335 (review)

    Connection can now remain alive after it has been disconnected, but the class does not define which methods are safe to call afterwards.

    Added some documentation that after calling disconnect method methods that perform i/o won't work. In general, I would like to make methods safe to call and avoid having unnecessary restrictions.

  11. in src/mp/proxy.cpp:262 in 631d8d9d43
     253 | @@ -254,6 +254,15 @@ void Connection::disconnect()
     254 |      m_stream = nullptr;
     255 |  }
     256 |  
     257 | +void Connection::waitDrained()
     258 | +{
     259 | +    // Blocking the event loop thread here would deadlock: in-flight call
     260 | +    // bodies sync() back to the event loop to deliver their results, and
     261 | +    // server objects are destroyed on the event loop thread.
     262 | +    assert(std::this_thread::get_id() != m_loop->m_thread_id);
    


    enirox001 commented at 3:24 PM on August 20, 2026:

    In https://github.com/bitcoin-core/libmultiprocess/pull/335/changes/631d8d9d439e22ea782d469c5ce4c75e2ee3d58c: ipc: add Connection::waitDrained() to wait for in-flight server calls

    The documentation for the waitDrained method in proxy-io.h says it is meant to be called after disconnect() call, but does nothing to enforce it, i think we can assert that the disconnect method has been called before calling waitDrained as such

    index 0aaa58a..6a318b0 100644
    --- a/src/mp/proxy.cpp
    +++ b/src/mp/proxy.cpp
    @@ -261,6 +261,7 @@ void Connection::waitDrained()
         // bodies sync() back to the event loop to deliver their results, and
         // server objects are destroyed on the event loop thread.
         assert(std::this_thread::get_id() != m_loop->m_thread_id);
    +    assert(m_disconnected);
         m_server_objects->wait();
     }
    

    ryanofsky commented at 7:26 PM on September 4, 2026:

    re: #335 (review)

    i think we can assert that the disconnect method has been called before calling waitDrained as such

    Was there a specific scenario that made this assert seem useful? It should be fine to call waitDrained regardless of whether the disconnect method was called. It would seem useful to do that if you want to detect when there's a disconnect and server objects are no longer in use, and don't care whether the disconnect was initiated locally or remotely.

  12. in include/mp/proxy-io.h:553 in 631d8d9d43
     548 | +    //!
     549 | +    //! This lets shutdown code ensure no IPC call body is still executing (and
     550 | +    //! dereferencing application state that is about to be freed) after
     551 | +    //! incoming connections are disconnected. See Ipc::disconnectIncoming and
     552 | +    //! https://github.com/bitcoin/bitcoin/issues/35845.
     553 | +    void waitDrained();
    


    enirox001 commented at 3:57 PM on August 20, 2026:

    In https://github.com/bitcoin-core/libmultiprocess/pull/335/changes/631d8d9d439e22ea782d469c5ce4c75e2ee3d58c: ipc: add Connection::waitDrained() to wait for in-flight server calls

    nit: The name waitDrained could be clearer if it is called waitServerCallsDrained? or at least document its exact scope a bit more clearly


    ryanofsky commented at 7:37 PM on September 4, 2026:

    re: #335 (review)

    The name waitDrained could be clearer if it is called waitServerCallsDrained? or at least document its exact scope a bit more clearly

    This is a difficult method to name and I think it might be better to focus on improving documentation if something is unclear. The specific problem with waitServerCallsDrained is this doesn't just wait for calls to finish it also waits for objects to be released.

    Fundamentally this method is meant to be useful for waiting until it is safe to free resources associated with the connection, and I think it makes sense to name it after its purpose instead after how it happens to be implemented at the moment. It could easily be implemented other ways such as by counting calls directly instead of on relying on PassField for mp.Context parameters using thisCap.

    I did make a number of documentation updates here and the main place semantics of object counting are explained is the ServerObjectTracker documentation comment.

  13. in test/mp/test/test.cpp:472 in 092d1db8fe outdated
     467 | +
     468 | +    // Disconnect. This cancels the call's promise (the client above sees the
     469 | +    // disconnect error), but the body is still blocked on the worker thread,
     470 | +    // so its server object must still be alive.
     471 | +    foo->m_context.loop->sync([&] { connection->disconnect(); });
     472 | +    KJ_EXPECT(connection->pendingServerObjects() == 1);
    


    enirox001 commented at 6:39 PM on August 20, 2026:

    In commit https://github.com/bitcoin-core/libmultiprocess/pull/335/changes/092d1db8fe7531e220c90bab0c4a78c83bdd978c: test: cover draining in-flight server call after disconnect

    I think using an exact count of one is a bit brittle; the test only needs to establish that something remains in flight. This would be less implementation-specific

    index 5bccb86..da47fde 100644
    --- a/test/mp/test/test.cpp
    +++ b/test/mp/test/test.cpp
    @@ -463,13 +463,13 @@ KJ_TEST("Waiting for in-flight server call to finish after disconnect")
    
         // The FooInterface server object is the connection's only counted server
         // object, and its call body is executing.
    -    KJ_EXPECT(connection->pendingServerObjects() == 1);
    +    KJ_EXPECT(connection->pendingServerObjects() > 0);
    
         // Disconnect. This cancels the call's promise (the client above sees the
         // disconnect error), but the body is still blocked on the worker thread,
         // so its server object must still be alive.
         foo->m_context.loop->sync([&] { connection->disconnect(); });
    -    KJ_EXPECT(connection->pendingServerObjects() == 1);
    +    KJ_EXPECT(connection->pendingServerObjects() > 0);
    
         // A drain must block while the body runs and return only once it
         // finishes, which is what Ipc::disconnectIncoming relies on during
    

    ryanofsky commented at 8:11 PM on September 11, 2026:

    re: #335 (review)

    I think using an exact count of one is a bit brittle; the test only needs to establish that something remains in flight. This would be less implementation-specific

    It is true test could be a little more brittle, but I think checking actual counts makes the test easier to understand and makes the code match the comments. But I would agree if there was a change that did cause these checks to break, that would be evidence these are too brittle, and would be good to make checks less strict at that point.

  14. in test/mp/test/test.cpp:484 in 092d1db8fe outdated
     479 | +        connection->waitDrained();
     480 | +        drained = true;
     481 | +    });
     482 | +
     483 | +    // The body is still blocked, so waitDrained() must not have returned.
     484 | +    std::this_thread::sleep_for(std::chrono::milliseconds(20));
    


    enirox001 commented at 6:46 PM on August 20, 2026:

    In commit https://github.com/bitcoin-core/libmultiprocess/pull/335/changes/092d1db8fe7531e220c90bab0c4a78c83bdd978c: test: cover draining in-flight server call after disconnect

    The 20 ms check is a bit too scheduler dependent, if the drain thread has not been scheduled during that interval, drained remains false even if waitDrained() is broken and would return immediately causing false pass.

    We could add a hook here that runs only when ServerObject::wait() sees a non zero count and is about to wait

    index 1f77b26..ab506b6 100644
    --- a/include/mp/proxy-io.h
    +++ b/include/mp/proxy-io.h
    @@ -494,12 +494,14 @@ struct ServerObjectTracker
         void wait()
         {
             Lock lock(m_mutex);
    +        if (m_count != 0 && testing_hook_wait) testing_hook_wait();
             m_cv.wait(lock.m_lock, [this]() MP_REQUIRES(m_mutex) { return m_count == 0; });
         }
    
         mutable Mutex m_mutex;
         std::condition_variable m_cv;
         size_t m_count MP_GUARDED_BY(m_mutex){0};
    +    std::function<void()> testing_hook_wait;
     };
    
     //! Object holding network & rpc state associated with either an incoming server
    diff --git a/test/mp/test/test.cpp b/test/mp/test/test.cpp
    index 5bccb86..eec52f6 100644
    --- a/test/mp/test/test.cpp
    +++ b/test/mp/test/test.cpp
    @@ -474,13 +474,17 @@ KJ_TEST("Waiting for in-flight server call to finish after disconnect")
         // A drain must block while the body runs and return only once it
         // finishes, which is what Ipc::disconnectIncoming relies on during
         // shutdown.
    +    std::promise<void> drain_waiting;
    +    connection->m_server_objects->testing_hook_wait = [&] { drain_waiting.set_value(); };
         std::atomic<bool> drained{false};
         std::thread drain_thread([&] {
             connection->waitDrained();
             drained = true;
         });
    
    -    // The body is still blocked, so waitDrained() must not have returned.
    +    // Wait until waitDrained() has observed the live server object and is
    +    // about to block, then verify it does not return while the body is blocked.
    +    drain_waiting.get_future().get();
         std::this_thread::sleep_for(std::chrono::milliseconds(20));
         KJ_EXPECT(!drained);
    

    The test will then wait for that hook before starting the 20ms check. This ensures the drain thread has entered wait and observed pending work. This substantially reduces the possibility of a false positive


    ryanofsky commented at 8:06 PM on September 11, 2026:

    re: #335 (review)

    The 20 ms check is a bit too scheduler dependent, if the drain thread has not been scheduled during that interval, drained remains false even if waitDrained() is broken and would return immediately causing false pass.

    Thanks! Applied patch in latest push

  15. in src/mp/proxy.cpp:516 in 901a090da0 outdated
     512 | +    // Destroy the thread client maps entry by entry: remove each entry from
     513 | +    // its map while holding Waiter::m_mutex, since event loop threads
     514 | +    // concurrently remove entries when connections are broken (see SetThread
     515 | +    // cleanup function), then destroy the removed ProxyClient<Thread> with the
     516 | +    // mutex released, since its destructor needs to lock EventLoop::m_mutex
     517 | +    // and Waiter::m_mutex must not be held when EventLoop::m_mutex is
    


    enirox001 commented at 8:19 PM on August 20, 2026:

    In commit https://github.com/bitcoin-core/libmultiprocess/pull/335/changes/901a090da03ed6b04d46a4040ed16fdeb238e7fd: Fix thread map teardown race causing use-after-free on disconnect

    The Waiter documentation says

    //! This mutex can be held at the same time as
    //! EventLoop::m_mutex as long as Waiter::mutex is locked first and
    //! EventLoop::m_mutex is locked second.
    

    But the new commit says

    //! Waiter::m_mutex must not be held when EventLoop::m_mutex is
    //! acquired
    

    It also says releasing the waiter mutex avoids locking the Waiter mutex before the EventLoop mutex, these rules cannot both be correct.

    I think an actual order should be identified and updated here


    ryanofsky commented at 8:13 PM on September 11, 2026:

    re: #335 (review)

    I think an actual order should be identified and updated here

    Nice catch. This is actually not the first comment that got the order backwards so I added a new commit to base PR #361 to improve all the documentation about lock order. (It's part of #361 not this PR because I also moved the "Fix thread map teardown race causing" commit you referenced to #361.)

  16. enirox001 commented at 8:23 PM on August 20, 2026: contributor

    Code Review https://github.com/bitcoin-core/libmultiprocess/pull/335/changes/901a090da03ed6b04d46a4040ed16fdeb238e7fd

    Separating connection teardown from destruction and providing a server-call drain functioanlity is a good addition. The overall approach makes sense. I intend to review this more

    I think the commit messages and code documentation are a bit too verbose. The explanations are nice to have, but it overexplains quite often, which ultimately makes it a bit harder to understand. Would suggest some revisions to the commit messages and the documentation to increase clarity

    In commit https://github.com/bitcoin-core/libmultiprocess/pull/335/changes/39cc757fba71871b154d915b02cf3967fe73fe0d: ipc: add Connection::disconnect() separating teardown from destruction

    I also think this is not exactly a behavior-neutral change; the commit message itself says Two details are new: as we now explicitly cancel m_on_disconnect handlers before severing the connection, and explicitly release m_thread_pool and m_thread_map during disconnect() rather than relying on member destruction.

    The m_on_disconnect change is especially not something I would call behavior-neutral, as now we have to proactively cancel because Connection remains alive after the transport is severed and is no longer a consequence of destruction teardown. So even though the externally observable behaviour might seem unchanged, the lifetime and cancellation behaviour has changed, and I think that distinction matters

    So the text saying

    “This is a behavior-neutral refactor: the same steps run in the same order on destruction.”
    

    is a bit misleading i think?

    Also, in commit https://github.com/bitcoin-core/libmultiprocess/pull/335/changes/a40189f5bbed89f09e5d63ce8377dc90574983f6, there does not seem to be a clear commit title and description here; they are together

    Left a few more suggestions and nits below

  17. ryanofsky commented at 9:25 PM on August 20, 2026: collaborator

    Thanks for the review! Great catches and suggestions. Just left some quick feedback below to make sure I didn't miss anything

  18. xyzconstant commented at 4:39 AM on August 26, 2026: contributor

    Code review 901a090

    I agree with @enirox001 that some comments and commit messages are quite confusing. Some are written like a story, e.g., "Previously..." clauses that add little value to the code. I had to ignore them because reading the code itself was simpler for me to understand the changes.

    Planning to review again once there are more updates.

  19. This is a documentation-only change meant to make upcoming commits easier to
    understand.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    801380139b
  20. ryanofsky force-pushed on Sep 4, 2026
  21. ryanofsky commented at 10:06 PM on September 4, 2026: collaborator

    Thanks for the reviews! I implemented fixes for the two preexisting bugs that were pointed out here in a new PR #361, that this PR is now based on so it would makes sense to review that PR first. I've partially addressed some other comments here as well but am still working on things.

    Rebased 901a090da03ed6b04d46a4040ed16fdeb238e7fd -> 33ab2153d47fd673db1a6b61bb064e262f9241ba (pr/keepconn.4 -> pr/keepconn.5, compare)<!-- end --> based on #361 implementing most review feedback

  22. Correct the ThreadContext "Synchronization note", which said
    Waiter::m_mutex must not be locked before EventLoop::m_mutex. That is
    the reverse of the documented and actual lock order (Waiter::m_mutex
    first, as ~ProxyServer<Thread> does). The constraint it was reaching for
    is the EventLoop blocking rule now documented on Waiter::m_mutex.
    
    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01YKQfSnMnUzqyDxKp7GpFam
    e9bbe34e30
  23. proxy-io: fix listener stuck at capacity after a local disconnect
    Currently, a ListenConnections listener that reaches its max-connection limit
    stops accepting new connections permanently if one of its connections is closed
    locally instead of by a remote disconnect. Closing a connection locally (e.g.
    erasing it from m_incoming_connections) leaves the listener's active-connection
    count stuck at the limit, so it never resumes accepting.
    
    This happens because the count is decremented by a callback which only fires on
    a remote disconnects, not local disconnects. Fix by moving the decrement to
    callback which fires on both local and remote disconnects.
    
    Add a regression test that closes a connection locally and checks the listener
    resumes accepting; it fails before this change (the listener never accepts the
    waiting client) and passes after.
    
    Co-Authored-By: Enoch Azariah <enirox001@gmail.com>
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    33bd6f833f
  24. proxy-io: fix race deleting a disconnected Connection twice
    Fix a use-after-free, possible since the destroy_connection option was added
    in 2019 (c685fa9): a Connection's disconnect handler could run after the
    Connection had already been destroyed, deleting it a second time and
    crashing. Reported by enirox001 in
    https://github.com/bitcoin-core/libmultiprocess/pull/335#discussion_r3821831654
    
    Give each Connection a shared_ptr "alive" token that disconnect handlers hold
    a weak_ptr to and check before running, so a handler is skipped once its
    Connection is gone. Having this check also enables the simplifications
    described below.
    
    Previously each Connection kept its disconnect handlers in its own
    kj::TaskSet, and when the network disconnected it moved a handler onto the
    shared event loop TaskSet with kj::evalLater. Destroying the Connection
    destroyed that per-connection TaskSet, canceling a still-pending handler --
    but a handler already moved onto the shared TaskSet was no longer canceled
    and could run after the Connection was gone. (The evalLater step existed only
    to avoid a "promise callback destroyed itself" error when a handler deletes
    its own Connection, which the per-connection TaskSet made possible.)
    
    With the token doing the cancellation, neither the per-connection TaskSet nor
    the evalLater step is needed, and both are removed.
    
    Co-Authored-By: Enoch Azariah <enirox001@gmail.com>
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    7cbade8b18
  25. Fix a race between a thread exiting after making IPC calls and its
    connection being destroyed on the event loop thread, which could destroy
    the same ProxyClient<Thread> object twice. ~ThreadContext destroyed the
    thread-local request_threads/callback_threads maps with no locking while
    the SetThread cleanup callback run by ~Connection erased entries from the
    same maps. When both ran at once, each side destroyed the entry's
    ProxyClient<Thread>, and ~Connection then ran the ProxyClientBase
    disconnect callback on the freed map node (heap-use-after-free, then a
    glibc "double free or corruption" abort).
    
    Fix by making map entry removal decide which side destroys an entry:
    ~ThreadContext and the SetThread callback each remove entries under
    Waiter::m_mutex before destroying them, and a side that finds an entry
    already gone leaves it to the other. See the code comments for why the
    entries are destroyed with the mutex released.
    
    Add a regression test, "Thread exiting while its connection is
    destroyed", which uses a new testing_hook_thread_client_destroy hook to
    interleave the two sides deterministically and fails on every run
    without the fix.
    
    The race is long-standing and reachable on master via connections
    created by ConnectStream, whose onDisconnect handler deletes the client
    Connection on the event loop thread when the peer disconnects.
    
    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01BnBLP1xuPf4fLpnQto8mEX
    Claude-Session: https://claude.ai/code/session_01YKQfSnMnUzqyDxKp7GpFam
    3a4a5eb5f5
  26. proxy-io: add Connection::disconnect() separating teardown from destruction
    Split connection teardown out of ~Connection into an idempotent disconnect()
    method, with the destructor delegating to it. For existing callers, this is a
    behavior-neutral refactor: the same steps run in the same order on destruction.
    
    Having a separate disconnect() method allows severing a connection while
    keeping the Connection object alive, which the next commits use to let
    shutdown code wait for in-flight server call bodies to finish after a
    disconnect (bitcoin/bitcoin#35845). Two details are new in the disconnect()
    method which were not present in the destructor method:
    
    - disconnect() expires the m_alive token explicitly, where previously it was
      expired implicitly by member destruction. This keeps onRemoteDisconnect able
      to distinguish a local disconnect from a remote one when a connection is
      severed without destroying the object (see the disconnect() code comment).
    
    - disconnect() explicitly releases m_thread_pool and m_thread_map so worker
      thread teardown happens at disconnect time whether or not the object is
      destroyed right away. Previously this happened implicitly during member
      destruction.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    d746bb39fa
  27. proxy-io: add Connection::waitDrained() to wait for in-flight server calls
    Add a per-connection ServerObjectTracker counting live ProxyServer objects,
    incremented in the ProxyServerBase constructor and decremented in its
    destructor, with Connection::waitDrained() blocking until the count reaches
    zero and Connection::pendingServerObjects() exposing it for logging.
    
    Disconnecting a connection cancels the KJ promise of an in-flight call, but a
    C++ server method body already dispatched to a worker thread runs to
    completion. Counting live server objects turns Cap'n Proto's object lifetime
    rules into a usable quiescence signal: a ProxyServer object is not destroyed
    until its outstanding calls finish (the target capability is kept alive for
    the duration of a call and pinned by post()/PassField via thisCap()), so
    after disconnect() the count drains to zero exactly when no server call body
    is still executing. Waiting for that lets shutdown code avoid freeing
    application state that a still-running call body dereferences
    (bitcoin/bitcoin#35845).
    
    The tracker is held via shared_ptr by the Connection and by every
    ProxyServer object because objects kept alive by in-flight calls can outlive
    the Connection on some teardown paths (see ~ProxyServerBase), and their
    destructors must decrement state that is still valid. It must be declared
    before m_rpc_system, whose construction creates the bootstrap server object
    that registers itself with the tracker.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    98d28df63e
  28. test: cover draining in-flight server call after disconnect
    Add a deterministic mptest regression test for bitcoin/bitcoin#35845: hold a
    server method body in flight on a worker thread, call
    Connection::disconnect(), and assert that Connection::waitDrained() blocks
    until the body finishes and its server object is destroyed. Also covers
    destroying an already-disconnected connection (~Connection noticing
    disconnect() has run).
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    Co-Authored-By: Enoch Azariah <enirox001@gmail.com>
    40cfb9332a
  29. ryanofsky referenced this in commit 088242bb08 on Sep 11, 2026
  30. ryanofsky force-pushed on Sep 11, 2026
  31. ryanofsky commented at 8:21 PM on September 11, 2026: collaborator

    <!-- begin push-6 -->

    Updated 33ab2153d47fd673db1a6b61bb064e262f9241ba -> 40cfb9332a1a47e4bc55f03aeb87787285f89975 (pr/keepconn.5 -> pr/keepconn.6, compare)<!-- end --> improving comments and naming and moving more changes to base PR #361 to simplify commits here.

    re: #335#pullrequestreview-4982982109

    I also think this is not exactly a behavior-neutral change

    Thanks, clarified commit message to say this is a behavior-neutral change for existing callers not calling the new methods. For callers that do specifically call the new disconnect() method, there are some differences from destroying the Connection object.


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-09-16 09:30 UTC

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