wallet: call SyncWithValidationInterfaceQueue after disconnecting chain notifications #34642

pull achow101 wants to merge 2 commits into bitcoin:master from achow101:failed-load-blockconnected-race changing 4 files +26 −3
  1. achow101 commented at 11:01 pm on February 20, 2026: member

    When the wallet disconnects chain notifications, it is expecting no further notifications to execute, but this is not the case. This results in test failures such as in #34354. Instead of disconnecting the notifications and continuing shutdown, we should wait for the validation interface queue to be drained before the rest of wallet shutdown. This is achieved by adding an interfaces::Chain::waitForNotifications() function which calls SyncWithValidationInterfaceQueue().

    Fixes #34354

  2. DrahtBot added the label Wallet on Feb 20, 2026
  3. DrahtBot commented at 11:01 pm on February 20, 2026: contributor

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

    Reviews

    See the guideline for information on the review process.

    Type Reviewers
    ACK furszy, stickies-v, rkrux, sedited

    If your review is incorrectly listed, please copy-paste <!–meta-tag:bot-skip–> into the comment that the bot should ignore.

    Conflicts

    Reviewers, this pull request conflicts with the following ones:

    • #33034 (wallet: Store transactions in a separate sqlite table by achow101)
    • #32895 (wallet: Prepare for future upgrades by recording versions of last client to open and decrypt by achow101)
    • #32489 (wallet: Add exportwatchonlywallet RPC to export a watchonly version of a wallet by achow101)

    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.

  4. achow101 added this to the milestone 31.0 on Feb 20, 2026
  5. in src/wallet/wallet.h:1076 in 563ec53d54
    1071@@ -1072,6 +1072,9 @@ class CWallet final : public WalletStorage, public interfaces::Chain::Notificati
    1072     //! Find the private key for the given key id from the wallet's descriptors, if available
    1073     //! Returns nullopt when no descriptor has the key or if the wallet is locked.
    1074     std::optional<CKey> GetKey(const CKeyID& keyid) const;
    1075+
    1076+    //! Disconnect chain notifications and wait for validation interfrace queue to drain
    


    stickies-v commented at 5:22 am on February 26, 2026:

    typo nit

    0    //! Disconnect chain notifications and wait for validation interface queue to drain
    

    achow101 commented at 9:14 pm on February 26, 2026:
    Done
  6. in src/interfaces/handler.h:31 in 563ec53d54
    25@@ -26,6 +26,9 @@ class Handler
    26 
    27     //! Disconnect the handler.
    28     virtual void disconnect() = 0;
    29+
    30+    //! Block until all notifications have been handled
    31+    virtual void sync() = 0;
    


    stickies-v commented at 12:29 pm on February 26, 2026:

    Adding a virtual sync method that’s no-op for all but one of its implementations doesn’t seem ideal. Would adding a Chain::waitForNotifications method be a workable alternative? E.g.:

      0diff --git a/src/common/interfaces.cpp b/src/common/interfaces.cpp
      1index 1c1a6f3203..ffd85e6131 100644
      2--- a/src/common/interfaces.cpp
      3+++ b/src/common/interfaces.cpp
      4@@ -17,7 +17,6 @@ public:
      5     explicit CleanupHandler(std::function<void()> cleanup) : m_cleanup(std::move(cleanup)) {}
      6     ~CleanupHandler() override { if (!m_cleanup) return; m_cleanup(); m_cleanup = nullptr; }
      7     void disconnect() override { if (!m_cleanup) return; m_cleanup(); m_cleanup = nullptr; }
      8-    void sync() override {}
      9     std::function<void()> m_cleanup;
     10 };
     11 
     12@@ -27,7 +26,6 @@ public:
     13     explicit SignalHandler(boost::signals2::connection connection) : m_connection(std::move(connection)) {}
     14 
     15     void disconnect() override { m_connection.disconnect(); }
     16-    void sync() override {}
     17 
     18     boost::signals2::scoped_connection m_connection;
     19 };
     20diff --git a/src/interfaces/chain.h b/src/interfaces/chain.h
     21index e6847b9b16..a0f288aee6 100644
     22--- a/src/interfaces/chain.h
     23+++ b/src/interfaces/chain.h
     24@@ -332,6 +332,9 @@ public:
     25     //! chain tip.
     26     virtual void waitForNotificationsIfTipChanged(const uint256& old_tip) = 0;
     27 
     28+    //! Wait for all pending notifications to be processed.
     29+    virtual void waitForNotifications() = 0;
     30+
     31     //! Register handler for RPC. Command is not copied, so reference
     32     //! needs to remain valid until Handler is disconnected.
     33     virtual std::unique_ptr<Handler> handleRpc(const CRPCCommand& command) = 0;
     34diff --git a/src/interfaces/handler.h b/src/interfaces/handler.h
     35index 6b8484f80f..b3aebf7e14 100644
     36--- a/src/interfaces/handler.h
     37+++ b/src/interfaces/handler.h
     38@@ -26,9 +26,6 @@ public:
     39 
     40     //! Disconnect the handler.
     41     virtual void disconnect() = 0;
     42-
     43-    //! Block until all notifications have been handled
     44-    virtual void sync() = 0;
     45 };
     46 
     47 //! Return handler wrapping a boost signal connection.
     48diff --git a/src/node/interfaces.cpp b/src/node/interfaces.cpp
     49index 3b2a726d10..2cc30ff519 100644
     50--- a/src/node/interfaces.cpp
     51+++ b/src/node/interfaces.cpp
     52@@ -499,10 +499,6 @@ public:
     53             m_proxy.reset();
     54         }
     55     }
     56-    void sync() override
     57-    {
     58-        m_signals.SyncWithValidationInterfaceQueue();
     59-    }
     60     ValidationSignals& m_signals;
     61     std::shared_ptr<NotificationsProxy> m_proxy;
     62 };
     63@@ -539,8 +535,6 @@ public:
     64             ::tableRPC.removeCommand(m_command.name, &m_command);
     65         }
     66     }
     67-    void sync() final {}
     68-
     69     ~RpcHandlerImpl() override { disconnect(); }
     70 
     71     CRPCCommand m_command;
     72@@ -790,6 +784,10 @@ public:
     73         if (!old_tip.IsNull() && old_tip == WITH_LOCK(::cs_main, return chainman().ActiveChain().Tip()->GetBlockHash())) return;
     74         validation_signals().SyncWithValidationInterfaceQueue();
     75     }
     76+    void waitForNotifications() override
     77+    {
     78+        validation_signals().SyncWithValidationInterfaceQueue();
     79+    }
     80     std::unique_ptr<Handler> handleRpc(const CRPCCommand& command) override
     81     {
     82         return std::make_unique<RpcHandlerImpl>(command);
     83diff --git a/src/wallet/wallet.cpp b/src/wallet/wallet.cpp
     84index 9e918599d5..1e7ad3fc6b 100644
     85--- a/src/wallet/wallet.cpp
     86+++ b/src/wallet/wallet.cpp
     87@@ -4582,7 +4582,7 @@ void CWallet::DisconnectChainNotifications()
     88 {
     89     if (m_chain_notifications_handler) {
     90         m_chain_notifications_handler->disconnect();
     91-        m_chain_notifications_handler->sync();
     92+        chain().waitForNotifications();
     93         m_chain_notifications_handler.reset();
     94     }
     95 }
     96diff --git a/src/wallet/wallet.h b/src/wallet/wallet.h
     97index b90668e64c..e6823c2441 100644
     98--- a/src/wallet/wallet.h
     99+++ b/src/wallet/wallet.h
    100@@ -1073,7 +1073,7 @@ public:
    101     //! Returns nullopt when no descriptor has the key or if the wallet is locked.
    102     std::optional<CKey> GetKey(const CKeyID& keyid) const;
    103 
    104-    //! Disconnect chain notifications and wait for validation interfrace queue to drain
    105+    //! Disconnect chain notifications and wait for validation interface queue to drain
    106     void DisconnectChainNotifications();
    107 };
    108 
    

    achow101 commented at 9:14 pm on February 26, 2026:
    Taken the suggestion
  7. stickies-v commented at 12:31 pm on February 26, 2026: contributor
    Concept ACK
  8. achow101 force-pushed on Feb 26, 2026
  9. furszy commented at 10:28 pm on February 26, 2026: member

    The current approach has a downside during IBD, any wallet unload during IBD could stall for hours because notification will keep coming. This wouldn’t be much a deal for an RPC thread but for the GUI, this would totally freeze the app.

    What we can do instead, is to unregister the wallet from the validation interface and submit a blocking task to the interface, so that we only wait up until the pending notifications gets cleared, and nothing else. Then we continue shutdown etc.

    I implemented it here https://github.com/furszy/bitcoin-core/commit/bf1abcd6d50374a461dad305f0243d3a613ce158. All yours (haven’t tested it).

    If we want more concurrency in the future, instead of waiting for the queue to drain, we can submit the remaining unload steps into the validation interface so they are executed only after the pending tasks get processed. I didn’t do it because it would require further changes that don’t think they are acceptable for the release freeze period.

  10. achow101 commented at 11:02 pm on February 26, 2026: member

    submit a blocking task to the interface, so that we only wait up until the pending notifications gets cleared, and nothing else. Then we continue shutdown etc.

    Isn’t that what SyncWithValidationInterfaceQueue does? It also calls CallFunctionInValidationInterfaceQueue, but with std::promise, and then it wait()s on that future.

  11. furszy commented at 11:17 pm on February 26, 2026: member

    Isn’t that what SyncWithValidationInterfaceQueue does? It also calls CallFunctionInValidationInterfaceQueue, but with std::promise, and then it wait()s on that future.

    ha yeah.. nvm. Bad brain, dunno why I was completely convinced it was waiting until the queue was empty instead 🤷🏼‍♂️.

  12. in src/interfaces/chain.h:335 in 1709609515
    331@@ -332,6 +332,9 @@ class Chain
    332     //! chain tip.
    333     virtual void waitForNotificationsIfTipChanged(const uint256& old_tip) = 0;
    334 
    335+    //! Wait for all pending notifications to be processed
    


    furszy commented at 11:19 pm on February 26, 2026:

    nano nit:

    0    //! Wait for all pending notifications up to this point to be processed
    

    stickies-v commented at 2:52 am on February 27, 2026:

    Or with even less ambiguity: “Wait for notifications that were pending before this call to be processed.”

    I don’t think this is a nano nit, important to be precise when describing interfaces. Also would prefer improving the handleNotifications docstring to highlight that there may be pending notifications when the handler is destroyed, referencing waitForNotifications. This might prevent similar issues in the future. For example:

    0    //! Register handler for notifications. Some notifications are asynchronous
    1    //! and may still execute after the handler is disconnected. Use
    2    //! waitForNotifications() after the handler is disconnected to ensure all
    3    //! pending notifications have been processed.
    4    virtual std::unique_ptr<Handler> handleNotifications(std::shared_ptr<Notifications> notifications) = 0;
    

    note: if no Notifications are registered, there can be no pending notifications, yet this will still block, so the docstring is still not entirely correct. But it’s consistent with pre-existing waitForNotificationsIfTipChanged, and I’m not sure there’s currenly a better approach without significant scope creep.


    furszy commented at 2:08 pm on February 27, 2026:
    @stickies-v, one option could be to move the listener unregistration into the interface so it mirrors the registration process (handleNotifications). That way we keep symmetry between the two and can enforce the requirements internally. See how I did it here https://github.com/furszy/bitcoin-core/commit/bf1abcd6d50374a461dad305f0243d3a613ce158 (note: the latch part can be replaced for a SyncWithQueue if not empty calls).

    achow101 commented at 8:16 pm on February 27, 2026:

    Updated the comment as suggested

    I think it would be better if disconnect() did the waiting, but that would be enlarging the scope of this PR to be more than I think is appropriate for a feature freeze fix. Since it would affect more than just the wallet, there may be other side effects that have to be considered as well.


    rkrux commented at 9:23 am on February 28, 2026:

    Some notifications are asynchronous

    Shouldn’t majority of the notifications in the codebase be asynchronous? Considering that the queueing mechanism is used usually used for async stuff (not specific to core).

    And I assume synchronous notifications are those that are called like how SyncWithValidationInterfaceQueue calls? https://github.com/bitcoin/bitcoin/blob/9cad97f6cdf1bd660732cd10e844a6a7e0771ea0/src/validationinterface.cpp#L146-L155

  13. furszy commented at 11:19 pm on February 26, 2026: member
    ACK c010fe8a2f9ec0dcee09a872da8926e0ee5f91a4
  14. DrahtBot requested review from stickies-v on Feb 26, 2026
  15. in src/wallet/wallet.h:1076 in c010fe8a2f
    1071@@ -1072,6 +1072,9 @@ class CWallet final : public WalletStorage, public interfaces::Chain::Notificati
    1072     //! Find the private key for the given key id from the wallet's descriptors, if available
    1073     //! Returns nullopt when no descriptor has the key or if the wallet is locked.
    1074     std::optional<CKey> GetKey(const CKeyID& keyid) const;
    1075+
    1076+    //! Disconnect chain notifications and wait for validation interface queue to drain
    


    stickies-v commented at 3:13 am on February 27, 2026:

    nit: better adhere to interface boundaries/terminology

    0    //! Disconnect chain notifications and wait for all notifications to be processed.
    

    stickies-v commented at 3:31 am on February 27, 2026:
    An alternative approach would be to always wait for notifications to be processed in disconnect(). Is my understanding that the reason we don’t is because during shutdown, the scheduler may already have been stopped? If so, I think this would be helpful to document DisconnectChainNotifications better so this doesn’t get refactored out in the future, it’s quite subtle I think.

    achow101 commented at 8:17 pm on February 27, 2026:
    Done as suggested.

    stickies-v commented at 5:17 am on February 28, 2026:

    If so, I think this would be helpful to document DisconnectChainNotifications better so this doesn’t get refactored out in the future, it’s quite subtle I think.

    It’s minor, but I think this comment has not yet been addressed. If my understanding is correct, a suggestion could be:

     0diff --git a/src/wallet/wallet.cpp b/src/wallet/wallet.cpp
     1index 1e7ad3fc6b..4fa6203d6d 100644
     2--- a/src/wallet/wallet.cpp
     3+++ b/src/wallet/wallet.cpp
     4@@ -4580,6 +4580,9 @@ std::optional<WalletTXO> CWallet::GetTXO(const COutPoint& outpoint) const
     5 
     6 void CWallet::DisconnectChainNotifications()
     7 {
     8+    // waitForNotifications() is called separately from disconnect() rather than integrated into it,
     9+    // because Shutdown() may tear down the scheduler before notifications are disconnected, causing
    10+    // the disconnection to block.
    11     if (m_chain_notifications_handler) {
    12         m_chain_notifications_handler->disconnect();
    13         chain().waitForNotifications();
    
  16. stickies-v commented at 3:41 am on February 27, 2026: contributor

    crACK c010fe8a2f9ec0dcee09a872da8926e0ee5f91a4

    PR description needs to be updated after the changed approach.

    Waiting for the chain notifications to be processed makes the wallet shutdown more correct, but also slower. In most cases this seems harmless, but I did not test if there are edge cases (e.g. IBD, multiple wallets, a somehow clogged up/slow validation queue) where this can be problematic.

  17. interfaces: Add waitForNotifications() to call SyncWithValidationInterfaceQueue()
    Co-Authored-By: stickies-v <stickies-v@protonmail.com>
    52992ebe1c
  18. wallet: Drain validation interface queue after notifications disconnect
    When unloading a wallet, there may be unexecuted callbacks in the
    validation interface queue that can still execute after we have
    completed all of the other wallet shutdown tasks. Instead of letting
    these run in the background, once the notifications are disconnected,
    wait for the queue to drain before continuing with wallet shutdown.
    98e8af4bb9
  19. achow101 force-pushed on Feb 27, 2026
  20. furszy commented at 0:12 am on February 28, 2026: member
    ACK 98e8af4bb991fd8edeb15c0fb8afa66bff6b5cac
  21. DrahtBot requested review from stickies-v on Feb 28, 2026
  22. stickies-v commented at 5:17 am on February 28, 2026: contributor
    utACK 98e8af4bb991fd8edeb15c0fb8afa66bff6b5cac
  23. rkrux approved
  24. rkrux commented at 9:29 am on February 28, 2026: contributor

    crACK 98e8af4bb991fd8edeb15c0fb8afa66bff6b5cac

    This makes the wallet unloading / shutdown operation more robust albeit slower (specially in case of multiple wallets loaded), which I don’t mind. I’d prefer consistent & slow(er) operations over brittle & fast(er) ones.

    I too feel that the waiting portion can be encapsulated within the disconnect function, but can be addressed later.

  25. sedited approved
  26. sedited commented at 8:59 pm on March 2, 2026: contributor
    ACK 98e8af4bb991fd8edeb15c0fb8afa66bff6b5cac
  27. sedited merged this on Mar 2, 2026
  28. sedited closed this on Mar 2, 2026


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-03-03 21:13 UTC

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