Bug 17
Wallet process hangs and keeps its wallets locked after the node is killed
- Status
- Fixed
- Symptom
- Deadlock; worst effect seen: deadlock
- Where seen
- Bitcoin Core functional tests, branch-build
- Trigger
- A client disconnects abruptly, for example because it is killed during a call
- First reported
- 2024-05-24 (first report)
- Fixed
- 2024-06-13
- Fix
- mp#101
- Shipped to Bitcoin Core
- bitcoin#30490
- In released binaries
- No; the code was in source releases only
- Defect ever on master
- Yes
Summary
When the connection between bitcoin-node and a spawned bitcoin-wallet process broke (for example because the node was killed), the wallet process hung forever and kept its wallet directory locked. When a connection breaks, libmultiprocess destroys the objects the process was serving to its peer. It destroyed them in the order they were created, so the wallet process destroyed its WalletLoader (the object the node uses to load and unload wallets) before the Chain::Notifications objects that still held references to the wallets. The WalletLoader destructor waited for those references forever. The multiprocess wallet exists only on the unmerged bitcoin#10102 branch, so no release could hit it. Fixed in mp#101 by running these cleanups in reverse creation order.
What happened
- @willcl-ark, driving a node from an external process on the bitcoin#10102 branch, reported that the node went down while a wallet was loaded and the wallet could not be reopened at the next startup (2024-05-24).
- After bitcoin#26606 ("wallet: Implement independent BDB parser") added a
tool_wallet.pycase that kills the node, the multiprocess CI job on the bitcoin#10102 branch failed: thebitcoin-walletprocess never exited after the node was killed, and loading the wallet afterwards failed because its directory was still locked. ryanofsky traced the hang to two separate deadlocks (2024-06-13). This page is about the second, which was fixed in libmultiprocess. The first, in theProxyServerCustom<WalletLoader>destructor shutting down the wallet's scheduler from the event loop thread, was fixed on the bitcoin#10102 branch and has its own page. willcl-ark confirmed it matched what he had seen; which of the two deadlocks his run hit cannot be told apart.
Root cause
When a connection breaks, Connection::~Connection moves its m_async_cleanup_fns list onto the event loop's async cleanup thread, which runs the entries one after another. The work is done on that separate thread because destroying a server object can block, and the event loop thread must not. The list has one entry per ProxyServer whose m_impl still needs destroying; ~ProxyServerBase adds it with addAsyncCleanup. Connection::addAsyncCleanup (src/mp/proxy.cpp) inserted at the front of the list. Cap'n Proto destroys the ProxyServer objects newest first, so front insertion reversed that, and the objects' implementations were destroyed oldest first.
In the wallet process the node-owned WalletLoader is created before the Chain::Notifications handlers (the CWallet objects). WalletLoaderImpl's destructor unloads the wallets and waits for every CWallet shared_ptr to be released, but the Chain::Notifications servers holding those pointers were later entries in the same list, which the single cleanup thread would only reach after the destructor returned. So the destructor waited forever.
The code
Quoted from 6825523c6e35^. When a connection breaks, Cap'n Proto destroys the connection's ProxyServer objects, and each ~ProxyServerBase (include/mp/proxy-io.h) hands the destruction of its implementation object to the connection:
m_context.connection->addAsyncCleanup([impl=std::move(m_impl), c=std::move(m_context.cleanup)]() mutable {
impl.reset();
Connection::addAsyncCleanup in src/mp/proxy.cpp puts each callback at the front of the list, and ~Connection then moves the list, front first, to the single async cleanup thread, which runs the callbacks one at a time in that order:
void Connection::addAsyncCleanup(std::function<void()> fn)
{
std::unique_lock<std::mutex> lock(m_loop.m_mutex);
m_async_cleanup_fns.emplace(m_async_cleanup_fns.begin(), std::move(fn));
}
// ...
while (!m_async_cleanup_fns.empty()) {
std::unique_lock<std::mutex> lock(m_loop.m_mutex);
m_loop.m_async_fns.emplace_back(std::move(m_async_cleanup_fns.front()));
m_async_cleanup_fns.pop_front();
}
Cap'n Proto destroys the servers newest first, and front insertion reverses that, so the implementations are destroyed oldest first. In the wallet process, when the node dies:
- Event loop: the
Chain::Notificationsservers (theCWallets, created later) and then theWalletLoaderserver (created earlier) are destroyed; the list ends up[WalletLoader, Notifications...]. - Async thread: runs
WalletLoaderImpl's destructor first, which unloads the wallets and waits for eachCWalletshared_ptrto be released. - The references it waits for are held by the
Notificationscallbacks later in the same list, which this thread only reaches after the destructor returns, sobitcoin-walletnever exits.
The fix appends instead (6825523c6e35, comments trimmed):
@@ -119,7 +127,23 @@ void Connection::removeSyncCleanup(CleanupIt it)
void Connection::addAsyncCleanup(std::function<void()> fn)
{
std::unique_lock<std::mutex> lock(m_loop.m_mutex);
- m_async_cleanup_fns.emplace(m_async_cleanup_fns.begin(), std::move(fn));
+ m_async_cleanup_fns.emplace(m_async_cleanup_fns.end(), std::move(fn));
}
Implementations are now destroyed in the order Cap'n Proto destroyed their servers, newest first, so the Notifications objects release their CWallet references before WalletLoaderImpl's destructor waits for them. This relies on creation order matching dependency order.
Fix
The fix commit 6825523c6e35, in mp#101 ("connection: run async cleanups in LIFO not FIFO order"), inserts async cleanups at the end of the list, keeping Cap'n Proto's newest-first order, and adds comments explaining why the order of the async list matters and the order of the sync cleanup list does not (+25/-1, one functional line). In the comment diagnosing the hang, ryanofsky described the fix as "still somewhat fragile", since it relies on creation order matching dependency order.
Timeline
- 2019-06-25: front insertion in
addAsyncCleanuppresent since the library's initial commit 06e1045baba0. - 2024-05-21: bitcoin#26606 merged, adding the first test that kills the node and expects the wallet process to shut down cleanly.
- 2024-05-24: @willcl-ark's report on bitcoin#10102.
- 2024-06-13: CI failure diagnosed; fix merged in mp#101 the same day.
- 2024-07-20: shipped to Bitcoin Core by bitcoin#30490 ("depends: bump libmultiprocess for CMake fixes"), which updated the libmultiprocess version Bitcoin Core builds against.
- Releases: not reachable in any release; the multiprocess wallet is only on the bitcoin#10102 branch.
Lessons
What would have prevented this bug, found it sooner, or made it easier to debug. The patterns page groups these lessons across all bugs.
Pattern Built or torn down in the wrong order: Teardown in the wrong order
- Prevented by a design rule
- Earliest detector a test that kills a process (did not exist then)
- Diagnosis moderate
- Fix design easy
- Debugging aids thread names, symbolized stack traces, a reproduction script
- Rust no
Would Rust have prevented it? No. Running deferred destructors oldest-first is an ordering choice; Rust drops locals in reverse and struct fields in declaration order, but a hand-built cleanup list runs in whatever order it is pushed and drained. More on Rust
Could it have been prevented?
Yes. C++ destroys members and locals in reverse construction order, and Cap'n Proto
destroys a broken connection's server objects newest first for the same reason: a later
object may depend on an earlier one. Connection::addAsyncCleanup broke the convention
with one word, inserting at begin() instead of end(), so the deferred destructors ran
oldest first. The rule "any deferred teardown list must preserve the order the framework
would have used" was knowable in 2019 and is what the fix commit
(6825523c6e35)
writes into the comment. It is still only a convention: the fix relies on creation order
matching dependency order, which ryanofsky called
"still somewhat fragile",
and a wallet whose WalletLoader waits on shared_ptr counts is sensitive to that
order in a way most server objects are not.
Could it have been found sooner?
Yes, by any test that kills the node while a spawned wallet process has a wallet loaded.
The order had been wrong for five years, but the C++ client shuts connections down
cleanly, calling destroy on each object, so the async cleanup list was only ever run
with dependent objects still in it after an unclean disconnect. No test did that until
bitcoin#26606 added a tool_wallet.py
case that kills the node for an unrelated reason (2024-05); the first multiprocess CI
run of the bitcoin#10102 branch after that rebase hung deterministically. willcl-ark had
seen the locked wallet directory
three weeks earlier from an external client, which is the same failure without a test
around it.
What made it hard to debug?
Two deadlocks shared one symptom: the same hang was also caused by the
ProxyServerCustom<WalletLoader> destructor stopping the scheduler on the event-loop
thread (sibling record),
so fixing the first still left a hung wallet process, and willcl-ark's report could not
be attributed to either. The CI evidence was a hung job with a lock error afterwards and
no stack traces; the
diagnosis
needed the kill test run locally with thread stacks in hand. Once seen, a single
cleanup thread blocked in ~WalletLoaderImpl with Notifications entries still queued
behind it reads directly, and the fix took the same day. Dumping all thread stacks from
a hung test job, and named threads so the async cleanup thread is recognisable, would
have made it a one-look diagnosis.
Lesson
When you defer destruction to a list or queue, keep the order the language would have used, newest first; a cleanup list that runs oldest first will deadlock the first time an early object waits on a later one.
Classification
The survey's classification of this bug. The explorer charts these fields across all bugs; the method page explains them.
| Field | Value |
|---|---|
| Symptom | deadlock |
| Worst effect observed | deadlock |
| Seen in | functional-test, branch-build |
| Scope | production |
| Trigger | Client disconnects abruptly (client-unclean-disconnect) |
| Phase | shutdown |
| Side | server |
| Component | connection |
| Root cause kind | ordering |
| Fix kind | one-line-tweak |
| Detection | user-report |
| Needs a peer that is not libmultiprocess C++ | no |
| Needed a sanitizer to see | no |
| Defect ever on master | yes |
| Released | source |
| Confidence | high |