btcsignals: destroy callbacks on disconnect #36011

pull ryanofsky wants to merge 3 commits into bitcoin:master from ryanofsky:pr/sigdest changing 2 files +140 −13
  1. ryanofsky commented at 4:28 PM on August 18, 2026: contributor

    Currently callback functions connected to signals do not get freed when their connection::disconnect method is called or when their associated scoped_connection is destroyed. They live as long as the signal does, even if they will never be called, and can only get garbage collected if new connections are added.

    This behavior is different that previous boost signals behavior and causes a deadlock on GUI shutdown in #10102 because when the node registers signal callbacks (handleInitMessage, etc) that get forwarded to the GUI, the callbacks own mp::EventLoopRef references, and if the callbacks aren't freed, the node process can't shut down.

    This change restores boost signals behavior freeing callbacks when they are disconnected or when scoped_connection objects are destroyed to avoid this problem and make callback lifetimes more predictable.

  2. test: characterize btcsignals callback destruction timing
    Add tests pinning down when a callback object (and any state it owns)
    is destroyed. Currently disconnect() only disables the callback: the
    object stays alive until a later connect() call on the same signal
    garbage collects it and every connection handle referencing it has been
    released; with no connection handle held it lives until the signal
    itself is destroyed.
    
    This differs from boost::signals2, which btcsignals aims to be
    api-compatible with: there, disconnecting destroys the slot's function
    object as soon as no emission is using it.
    
    No behavior change; these tests document the status quo so the next
    commit changing the destruction timing has a clear before/after diff.
    
    This change was written with Claude Fable 5.
    7c5571adc4
  3. btcsignals: destroy callbacks eagerly on disconnect
    Previously a disconnected callback and any state it owned could stay
    alive indefinitely: destruction required both a later connect() call on
    the same signal to garbage collect it and release of every connection
    handle referencing it. That made destruction timing unpredictable for
    callbacks that own resources whose release has side effects, and it
    diverges from boost::signals2, which destroys a disconnected slot's
    function object as soon as no emission is running it. (The concrete
    fallout was in multiprocess bitcoin-gui, where node notification
    callbacks own IPC proxy objects: their deferred destruction kept
    bitcoin-node from exiting and hung interface_gui.py until timeout.)
    
    Make disconnect() destroy the callback, deferring only while a
    concurrent emission is mid-call into it, matching boost::signals2
    semantics. The previous commit's characterization tests are updated;
    their diff shows the ownership change.
    
    This change was written with Claude Fable 5.
    f1d786c1ef
  4. btcsignals: fall back to atomic_load/store without __cpp_lib_atomic_shared_ptr
    std::atomic<shared_ptr<T>> is a C++20 library feature, but library
    implementations are not required to ship all C++20 features at once;
    __cpp_lib_atomic_shared_ptr is the feature-test macro that indicates its
    presence. Without it, the generic std::atomic<T> requires trivially copyable
    T — shared_ptr is not, causing build failures on macOS (Xcode 16.2),
    FreeBSD 15.1, and the MSan/TSan custom libc++ builds in CI.
    
    Fall back to the C++11 atomic_load/atomic_store free functions, which are
    specifically overloaded for shared_ptr, through a small atomic_shared_ptr
    wrapper with the same load() and store() members. The fallback is intended
    to be temporary: those functions are deprecated in C++20 and will eventually
    be removed, so this commit can be reverted once all CI platforms carry the
    C++20 specialization.
    
    This change was written with Claude Sonnet 4.6.
    a318b23131
  5. DrahtBot commented at 4:28 PM on August 18, 2026: contributor

    <!--e57a25ab6845829454e8d69fc972939a-->

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

    <!--006a51241073e994b41acfe9ec718e94-->

    External sites

    <!--021abf342d371248e50ceaed478a90ca-->

    Reviews

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

    Type Reviewers
    ACK hodlinator, sedited

    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:

    • #10102 <sub><img src="https://drahtbot.space/ack_count/bitcoin/bitcoin/10102.svg"></sub> (Multiprocess bitcoin 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-->

  6. fanquake requested review from theuni on Aug 18, 2026
  7. DrahtBot added the label CI failed on Aug 18, 2026
  8. maflcko commented at 10:26 AM on August 19, 2026: member

    Heh, I really loved the simple garbage collect logic. Too bad it doesn't work for the mp-gui. I guess this can slip the next major release, as there is no rush, right?

  9. DrahtBot removed the label CI failed on Aug 19, 2026
  10. ryanofsky commented at 12:24 PM on August 19, 2026: contributor

    Yeah no rush, and there are also some potential simplifications this change could allow. This PR is trying to make behavior of btcsignals more predictable and free the external callbacks after they can no longer be called. But internal improvements might be possible too:

    • connection::liveness::m_connected variable could maybe be dropped. It now seems redundant with the m_owner variable.
    • Maybe the garbage collection loop can be dropped and replaced with an m_owner custom deleter that deletes entries from m_connections
    • I'm not sure if m_callback is actually necessary. Maybe the callback pointer could be derived from m_owner with a cast.
  11. sedited commented at 9:40 AM on October 6, 2026: contributor

    Approach ACK

    I'm not sure if m_callback is actually necessary. Maybe the callback pointer could be derived from m_owner with a cast.

    I think I'd prefer this done here. It took me a couple of reads to understand why this is done at all. How about:

    diff --git a/src/util/btcsignals.h b/src/util/btcsignals.h
    index 367fcfe851d..74f455fd6f1 100644
    --- a/src/util/btcsignals.h
    +++ b/src/util/btcsignals.h
    @@ -187 +186,0 @@ class signal
    -            m_callback = owner.get();
    @@ -193,5 +192,5 @@ class signal
    -        std::shared_ptr<const void> owner() const { return m_owner.load(); }
    -
    -        //! Raw pointer to the callback, only valid while holding a shared_ptr
    -        //! returned by owner().
    -        const function_type* m_callback;
    +        std::shared_ptr<const function_type> owner() const
    +        {
    +            auto owner = m_owner.load();
    +            return std::static_pointer_cast<const function_type>(std::move(owner));
    +        }
    @@ -249 +248 @@ public:
    -                    (*connection->m_callback)(args...);
    +                    (*owner)(args...);
    @@ -260 +259 @@ public:
    -                    ret |= (*connection->m_callback)(args...);
    +                    ret |= (*owner)(args...);
    
  12. in src/util/btcsignals.h:135 in 6a3d490d82 outdated
     128 | @@ -94,8 +129,10 @@ class connection
     129 |       * If a connection is disabled as part of a signal's callback function, it
     130 |       * will _not_ be executed in the current signal invocation.
     131 |       *
     132 | -     * Note that disconnected callbacks are not removed from their owning
     133 | -     * signals here. They are garbage collected in signal::connect().
     134 | +     * The callback object is destroyed here (or when the last concurrently
     135 | +     * executing invocation of it completes). The empty holder entry is not
     136 | +     * removed from the owning signal; it is garbage collected in
     137 | +     * signal::connect().
    


    ryanofsky commented at 9:10 AM on October 14, 2026:

    re: #36011 (comment)

    in commit "btcsignals: destroy callbacks eagerly on disconnect" (c61fa5768fdc291ab0fdbb71b1ab240cf1e6485e)

    note: i didn't follow up on my earlier idea of replacing the connect() garbage collection loop with a custom deleter that removes entries from m_connections. the deleter would need a pointer back to the signal, and since connection handles can outlive the signal, a late disconnect() would run it against a destroyed signal unless the signal's state were moved behind a shared_ptr/weak_ptr. it would also run on whichever thread drops the last reference, which could be a thread emitting the signal or the signal destructor, and would need to lock m_mutex there. that is more complexity than the loop, which now costs little: a disconnected entry no longer holds the callback, only the small liveness object, and the next connect() call removes it.

  13. ryanofsky force-pushed on Oct 14, 2026
  14. ryanofsky commented at 9:47 AM on October 14, 2026: contributor

    Thanks for the review!

    <!-- begin push-2 -->

    Updated 6a3d490d826a50da6ac812cbf204320d5780f7f3 -> 47a5fc767162ff1e141113f2ff68e42435310d47 (pr/sigdest.1 -> pr/sigdest.2, compare)<!-- end --> dropping redundant m_connected and m_callback members as suggested by sedited, adding a test for disconnecting a callback while it runs on another thread, and moving the atomic_load/atomic_store fallback into one #ifdef block so it is easy to revert later

  15. sedited approved
  16. sedited commented at 2:54 AM on October 15, 2026: contributor

    ACK 47a5fc767162ff1e141113f2ff68e42435310d47

  17. sedited requested review from hodlinator on Oct 19, 2026
  18. in src/util/btcsignals.h:204 in 47a5fc7671
     200 | @@ -157,11 +201,17 @@ class signal
     201 |       */
     202 |      struct connection_holder : connection::liveness {
     203 |          template <typename Callable>
     204 | -        connection_holder(Callable&& callback) : m_callback{std::forward<Callable>(callback)}
     205 | +        connection_holder(Callable&& callback)
    


    hodlinator commented at 12:51 PM on October 21, 2026:

    nit: Might as well make ctor explicit:

            explicit connection_holder(Callable&& callback)
    

    Same for atomic_shared_ptr(std::shared_ptr<T> ptr).


    ryanofsky commented at 10:10 PM on October 30, 2026:

    re: #36011 (review)

    Thanks, made both constructors explicit.

  19. in src/test/btcsignals_tests.cpp:110 in 47a5fc7671 outdated
     105 | +    // handle is still held.
     106 | +    btcsignals::signal<void()> sig1;
     107 | +    auto state1{std::make_shared<int>(0)};
     108 | +    std::weak_ptr<int> weak1{state1};
     109 | +    auto conn1{sig1.connect([state1 = std::move(state1)] {})};
     110 | +    conn1.disconnect();
    


    hodlinator commented at 1:03 PM on October 21, 2026:

    nit: Could verify state between connect/disconnect calls:

        auto conn1{sig1.connect([state1 = std::move(state1)] {})};
        BOOST_CHECK(!weak1.expired());
        conn1.disconnect();
    

    ryanofsky commented at 10:10 PM on October 30, 2026:

    re: #36011 (review)

    Thanks, added. With the check in the first commit, the test now shows that disconnect() is what destroys the callback in the second commit.

  20. hodlinator approved
  21. hodlinator commented at 1:42 PM on October 21, 2026: contributor

    light ACK 47a5fc767162ff1e141113f2ff68e42435310d47

    Simplifies the usage of signals somewhat as can be seen in the modifications to the new unit tests in the second commit.

    Made sure to test detail::atomic_shared_ptr by removing #ifdef wrappers.

    TIL atomic_load()/atomic_store() are specialized for shared_ptr* up until C++26 - https://en.cppreference.com/cpp/memory/shared_ptr/atomic

  22. sedited commented at 2:09 PM on October 21, 2026: contributor

    @ryanofsky let me know if you want to pick up the nits here.

  23. ryanofsky force-pushed on Oct 30, 2026
  24. ryanofsky commented at 10:14 PM on October 30, 2026: contributor

    re: #36011 (comment)

    @ryanofsky let me know if you want to pick up the nits here.

    Yes, looks like needs more review anyway so no reason not to. Thanks!

    <!-- begin push-3 -->

    Updated 47a5fc767162ff1e141113f2ff68e42435310d47 -> a318b23131e6df2c64e8fe79cebc8e5babdf8b7f (pr/sigdest.2 -> pr/sigdest.3, compare)<!-- end --> making constructors explicit and adding a test check, as suggested by hodlinator

  25. fanquake added this to the milestone 33.0 on Oct 30, 2026
  26. hodlinator approved
  27. hodlinator commented at 8:52 AM on October 31, 2026: contributor

    re-ACK a318b23131e6df2c64e8fe79cebc8e5babdf8b7f

    Thanks for grabbing my nits!

  28. DrahtBot requested review from sedited on Oct 31, 2026
  29. sedited approved
  30. sedited commented at 4:34 PM on October 31, 2026: contributor

    Re-ACK a318b23131e6df2c64e8fe79cebc8e5babdf8b7f

  31. sedited merged this on Oct 31, 2026
  32. sedited closed this on Oct 31, 2026

  33. hebasto commented at 9:58 AM on November 1, 2026: member

    This change appears to be responsible for a SEGFAULT during test_bitcoin-qt shutdown on OpenBSD: https://my.cdash.org/tests/560623779.

    Investigating...

    UPDATE. Cannot reproduce it for now.

  34. in src/test/btcsignals_tests.cpp:90 in a318b23131
      83 | @@ -81,6 +84,78 @@ BOOST_AUTO_TEST_CASE(disconnects)
      84 |      BOOST_CHECK_EQUAL(val, 6);
      85 |  }
      86 |  
      87 | +/* Characterize when a callback object and the state it owns are destroyed:
      88 | + * eagerly on disconnect(), or with the signal if never disconnected.
      89 | + * Connection handles do not keep the callback alive.
      90 | + */
    


    ryanofsky commented at 7:43 PM on November 3, 2026:

    In commit "btcsignals: destroy callbacks eagerly on disconnect" (f1d786c1ef5d813f61e405370a67df4cc6323885)

    Post-merge note: the last sentence of this comment should say "Connection handles do not keep a disconnected callback alive." Destroying the signal does not disconnect anything, so a callback that was never disconnected stays alive as long as a connection or scoped_connection handle to it exists, even after the signal is gone. This was the same before this PR, but it differs from boost::signals2, where handles are weak references and slots are freed with the signal.

    There are two ways this could be fixed:

    1. Make ~signal() disconnect its callbacks, so they are freed with the signal even while handles are held, as in boost. The comment would then be right as written.
    2. Keep the behavior and change the comment to the wording above.

    I'd lean toward the first, but would be happy to hear any preferences. Either way the test should cover this case, see #36011 (review).

  35. in src/test/btcsignals_tests.cpp:102 in a318b23131
      97 | +        auto state0{std::make_shared<int>(0)};
      98 | +        weak0 = state0;
      99 | +        sig0.connect([state0 = std::move(state0)] {});
     100 | +        BOOST_CHECK(!weak0.expired());
     101 | +    }
     102 | +    BOOST_CHECK(weak0.expired());
    


    ryanofsky commented at 7:43 PM on November 3, 2026:

    In commit "btcsignals: destroy callbacks eagerly on disconnect" (f1d786c1ef5d813f61e405370a67df4cc6323885)

    Post-merge note: this case drops the connection handle returned by connect(), so "destroyed with the signal" is only tested when no handle is held. A follow-up could add a case where a handle outlives the signal, which currently keeps the callback alive:

    // A connection handle that outlives the signal keeps a callback that was
    // never disconnected alive until the handle is released.
    std::weak_ptr<int> weak2;
    btcsignals::connection conn2;
    {
        btcsignals::signal<void()> sig2;
        auto state2{std::make_shared<int>(0)};
        weak2 = state2;
        conn2 = sig2.connect([state2 = std::move(state2)] {});
    }
    BOOST_CHECK(!weak2.expired());
    conn2 = {};
    BOOST_CHECK(weak2.expired());
    
  36. in src/util/btcsignals.h:103 in a318b23131
     100 | -        void disconnect() { m_connected.store(false); }
     101 | +        //! Callback object, type-erased so connection does not depend on the
     102 | +        //! signal type, and set to null by disconnect(). Signal invocation
     103 | +        //! holds a reference to it while calling it, so a callback
     104 | +        //! disconnected during a call is destroyed when the call returns.
     105 | +        detail::atomic_shared_ptr<const void> m_callback;
    


    ryanofsky commented at 7:43 PM on November 3, 2026:

    In commit "btcsignals: destroy callbacks eagerly on disconnect" (f1d786c1ef5d813f61e405370a67df4cc6323885)

    Post-merge note: the internal names here are confusing, and this PR made them worse. liveness now holds the callback itself, not a connected flag, and m_connections holds connection_holder entries, not connection handles. I think a rename like the following dropping liveness and holder terms could be an improvement:

    • connection::liveness → connection::entry: one entry in a signal's list, and what connection handles point to. It stores the callback type-erased, so disconnect() can free it without knowing the signal's type.
    • signal::connection_holder → signal::typed_entry: the same entry with typed access to the callback.
    • m_connections → m_entries, and connection::m_state → m_entry, to match.
  37. in src/util/btcsignals.h:293 in a318b23131


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

    re: maflcko #36011 (comment)

    In commit "btcsignals: destroy callbacks eagerly on disconnect" (f1d786c1ef5d813f61e405370a67df4cc6323885)

    Heh, I really loved the simple garbage collect logic.

    Note: this garbage collection loop is still here and works the same way, but now it only frees the small entry objects the signal keeps for each connection, so the lazy garbage collection no longer keeps user-provided callbacks alive.

  38. hebasto commented at 11:37 AM on November 5, 2026: member

    This change appears to be responsible for a SEGFAULT during test_bitcoin-qt shutdown on OpenBSD: https://my.cdash.org/tests/560623779.

    Investigating...

    UPDATE. Cannot reproduce it for now.

    Just got another shutdown issue on OpenBSD 8.0:

    ********* Start testing of AppTests *********
    Config: Using QtTest library 6.8.4, Qt 6.8.4 (x86_64-little_endian-lp64 static release build; by OpenBSD Clang 22.1.6), unknown unknown
    <snip>
    Totals: 3 passed, 0 failed, 0 skipped, 0 blacklisted, 877ms
    ********* Finished testing of AddressBookTests *********
    
    All tests passed.
    
    ~InitExecutor : Stopping thread
    ~InitExecutor : Stopped thread
    test_bitcoin-qt(78047) in free(): write to free mem 0xb5935add000[2568..2575]@4096
    

    I can't blame this PR's changes because they all look correct to me. However, they seem to have revealed a pre-existing race.

  39. in src/util/btcsignals.h:80 in f1d786c1ef
      77 | +        //! signal type, and set to null by disconnect(). Signal invocation
      78 | +        //! holds a reference to it while calling it, so a callback
      79 | +        //! disconnected during a call is destroyed when the call returns.
      80 | +        std::atomic<std::shared_ptr<const void>> m_callback;
      81 | +
      82 | +        void disconnect() { m_callback.store(nullptr); }
    


    ryanofsky commented at 3:57 PM on November 5, 2026:

    re: @hebasto #36011 (comment)

    I can't blame this PR's changes because they all look correct to me. However, they seem to have revealed a pre-existing race.

    Findings from Claude with a suggestion at the end on how to obtain a backtrace (MALLOC_OPTIONS=S):

    Thanks for the report. I looked at the Nov 5 output and at how OpenBSD's malloc produces that message, and I think two things are worth knowing before chasing this as a race.

    The check that fired is sampled, so a deterministic bug can look intermittent. write to free mem 0xb5935add000[2568..2575]@4096 comes from validate_junk() in OpenBSD's malloc.c. At the default junk level it writes and later re-checks only four 8-byte words per freed page, at an offset chosen randomly per process (junk_loc = arc4random()). So an 8-byte stale write at a fixed offset is caught in roughly 1 run out of 128, a 64-byte write in about 1 out of 16. The check also runs late: a freed page-sized region sits in a 64-slot cache and is validated only when it is randomly evicted by a later free(), so the write could have happened well before the ~InitExecutor lines, anywhere after the region was freed.

    The freed region is a 2049–4096 byte allocation, and probably not one of our classes. @4096 means a page-sized region, not a small chunk. OpenBSD places allocations between half a page and a page at the end of the page, and the message prints the page address, so page offset 2568 is roughly size − 1528 into the object. I dumped record layouts for the node, wallet and test TUs: nothing we allocate on the heap is in that size range except ChainstateManager (and that is too small to reach the offset). A container buffer, a libc++ std::deque block (exactly 4096 bytes) or a Qt-internal object fits better.

    What this PR changes at runtime is only when a callback object is destroyed: at disconnect(), or at the end of an in-flight invocation on the invoking thread, instead of when the signal is destroyed. Every callback connected to a btcsignals signal in the tree owns nothing beyond its own captures (this or other raw pointers, a wrapped std::function, one std::string in init.cpp), so destroying it earlier frees a few small chunks and touches nothing else; the only new writes go into refcounted holder objects. It can perturb heap layout and timing (libc++'s atomic_load fallback takes a lock from a global 16-mutex pool), which is consistent with your "revealed a pre-existing race" reading, but it is not a plausible writer itself.

    Since the regular ASan CI job runs test_bitcoin-qt too (and 30 local ASan/UBSan runs on Linux were clean), this looks like either an OpenBSD/libc++/static-Qt-specific problem or a rare race. The quickest way to find the writer is to make OpenBSD fault at the write instead of sampling it later:

    ulimit -c unlimited
    MALLOC_OPTIONS=S ./build/bin/test_bitcoin-qt
    

    S turns on full junking and validation and disables the page cache, so a stale write to a freed page-sized region SIGSEGVs at the writing instruction (U alone, which unmaps freed pages, has the same effect for this size class). The core in egdb then gives the stack of the writer; if it is a race, a loop of 20–50 runs should hit it. I'm happy to dig further if you can get that backtrace.


github-metadata-mirror

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

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