Bug 41
Node never finishes shutting down while an IPC client stays connected
- Status
- Fixed
- Symptom
- Hang; worst effect seen: hang
- Where seen
- Branch-build
- Trigger
- Shutdown
- First reported
- 2024-12-16 (first report)
- Fixed
- 2025-08-18
- In released binaries
- No; the code was in source releases only
- Defect ever on master
- Yes
Summary
bitcoin-node could not finish shutting down while a client connected through -ipcbind (the option that lets outside processes connect over a Unix socket) stayed connected. It logged Shutdown: done and then waited for every IPC client to disconnect on its own, which a client that ignored the node going away never did. Any external IPC client, such as a Stratum v2 mining client, could trigger it. v29.0 source builds with IPC enabled have the bug; fixed by bitcoin#32345 ("ipc: Handle unclean shutdowns better"), which disconnects incoming IPC clients during shutdown, before v30.0 shipped bitcoin-node.
What happened
- @darosior, testing the wallet-over-IPC branch bitcoin#29409 ("multiprocess: Add capnp wrapper for Chain interface") with his Rust
core_bdk_walletclient, reported on that PR (2024-12-16): "Stop the bitcoin-node process. It will hang waiting for the client to exit." Only stopping the client let the node exit, and it then segfaulted, which is a separate bug (a notificationsHandlerdestroyed afterValidationSignalswas freed). ryanofsky folded the report into mp#123, an issue collecting related shutdown failures. - @ismaelsadeeq, writing a custom IPC client (the
bitcoin-minetest program patched to sleep until Ctrl-C), reported on bitcoin#30437, the PR addingbitcoin-mine(2025-05-28), that afterbitcoin-cli stopthe node hung after shutdown until the client exited.
Root cause
After Shutdown() (src/init.cpp) returns, main() destroys the interfaces::Init object, whose CapnpProtocol destructor releases its event loop reference and joins the event loop thread. The loop only exits when its reference count reaches zero, and every incoming mp::Connection accepted through -ipcbind holds a reference, so the join waited for remote clients to disconnect. Nothing in the shutdown path closed those connections.
The shutdown design came from the spawned-process model, where the only connection is to a child process the node itself tears down. -ipcbind lets arbitrary external processes connect and outlive the node, and the first out-of-tree clients did not react to the node shutting down.
The code
Quoted from 7f65aac78b95^. After Shutdown() returns, destroying the node's CapnpProtocol (src/ipc/capnp/protocol.cpp) drops its own reference to the event loop and joins the loop thread:
~CapnpProtocol() noexcept(true)
{
m_loop_ref.reset();
if (m_loop_thread.joinable()) m_loop_thread.join();
assert(!m_loop);
};
The loop thread only returns once no references are left, and every connection holds one: Connection in src/ipc/libmultiprocess/include/mp/proxy-io.h has a member
EventLoopRef m_loop;
Connections accepted through -ipcbind live in EventLoop::m_incoming_connections until the remote side disconnects, and nothing in Shutdown() closes them. So with an external client still connected:
- Main thread:
Shutdown()runs to the end and logsShutdown: done. - Main thread:
~CapnpProtocolreleasesm_loop_refand blocks injoin(). - Event loop: keeps serving the client's connection, whose
EventLoopRefkeeps the loop alive, until the client process exits.
The fix adds disconnectIncoming() and calls it from Shutdown() after the chain clients stop (7f65aac78b95; the interface declarations and the parent-connection bookkeeping in serve() are omitted):
@@ -374,6 +374,12 @@ void Shutdown(NodeContext& node)
client->stop();
}
+ // If any -ipcbind clients are still connected, disconnect them now so they
+ // do not block shutdown.
+ if (interfaces::Ipc* ipc = node.init->ipc()) {
+ ipc->disconnectIncoming();
+ }
@@ -65,9 +65,20 @@ public:
+ void disconnectIncoming() override
+ {
+ if (!m_loop) return;
+ // Delete incoming connections, except the connection to a parent
+ // process (if there is one), since a parent process should be able to
+ // monitor and control this process, even during shutdown.
+ m_loop->sync([&] {
+ m_loop->m_incoming_connections.remove_if([this](mp::Connection& c) { return &c != m_parent_connection; });
+ });
+ }
Destroying the incoming Connection objects on the loop thread releases their EventLoopRefs during Shutdown(), so by the time ~CapnpProtocol joins, the loop has nothing left keeping it running.
Fix
Commit 7f65aac78b95 ("ipc: Avoid waiting for clients to disconnect when shutting down") adds Ipc::disconnectIncoming(), implemented in src/ipc/capnp/protocol.cpp as an m_loop->sync call (which runs code on the event loop thread and waits for it) that erases every entry of m_incoming_connections except a connection to a parent process, and calls it from Shutdown() right after the chain clients are stopped (+29). The same PR added unit tests for AppInitMain/Shutdown in node_init_tests.cpp (commit 6eb09fd6141f).
Timeline
- 2024-09-09: introduced by commit 955d4077aac6 ("multiprocess: Add IPC connectAddress and listenAddress methods"), merged in bitcoin#30509, the PR that added
-ipcbind. - 2024-12-16: first report by darosior on bitcoin#29409.
- 2025-05-28: report by ismaelsadeeq on bitcoin#30437.
- 2025-08-18: fix merged in bitcoin#32345.
- Releases: v29.0 has the bug in source builds with IPC enabled; v30.0, the first release to ship
bitcoin-node, has the fix.
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 Serving before ready, freeing before done: Shutdown waits on a peer it does not control
- Prevented by a design rule, a review checklist item
- Earliest detector a test with a non-C++ client (did not exist then)
- Diagnosis easy
- Fix design moderate
- Debugging aids logging, symbolized stack traces
- Rust no
Would Rust have prevented it? No. Waiting for a reference count held by peers the process does not control is a shutdown design gap; an Arc-counted loop in Rust hangs the same way. More on Rust
Could it have been prevented?
Yes. The event loop exits when its reference count reaches zero, and every incoming
connection holds a reference; that was a sound shutdown design while the only peer was
a child the node itself tears down. bitcoin#30509
added -ipcbind, which lets processes the node does not control connect, and
Shutdown() still had no way to close them, so the node waited for strangers. The rule
is "when you add a listener, add its eviction to shutdown in the same change", and the
review question "what does shutdown do with a connected client" answers itself from
~CapnpProtocol. The fix (7f65aac78b95)
is the eviction, Ipc::disconnectIncoming(), plus the one exception the design needed
(keep a parent process's connection).
Could it have been found sooner?
Yes, by a functional test that leaves a client connected and stops the node; it is a
timeout, not a race, so it fails on the first run. No in-tree client behaved that way:
the C++ tests and the node/wallet pairing always disconnected first, so the first
out-of-tree client (darosior's Rust wallet, three months after -ipcbind merged) was
the first to sit there. ismaelsadeeq's patched bitcoin-mine hit the same hang five
months later while the fix was still open. The PR added an in-process
Init/Shutdown unit-test harness, not this case.
What made it hard to debug?
Nothing about the cause: Shutdown: done followed by a hang localizes it to the
CapnpProtocol destructor's join, and the reference-count design explains the rest.
The fix took longer to settle than to write, because the placement of
disconnectIncoming() inside Shutdown() interacts with the handler-destruction
crash (the sibling record) and with the bitcoin-gui parent connection that must
survive shutdown; the December report also bundled this hang with that crash, and
a later report on bitcoin#32297
was attributed here but was a different hang (a wait that Ctrl-C never woke).
Lesson
Shutdown may wait only on things the process controls; the moment outside parties can hold a reference, the process needs an explicit way to drop them, written in the same change that let them in.
Notes
- A similar-looking hang reported by @pinheadmz on bitcoin#32297, the PR adding
bitcoin-cli -ipcconnect(2025-05-01: node printsShutdown: doneand never exits while abitcoin-cli waitforblockover IPC is outstanding), was put down at the time to issues bitcoin#32345 was fixing. The code points elsewhere: most likely the worker thread servingwaitforblocksat in a condition-variable wait that Ctrl-C never woke, which is the bug fixed by bitcoin#33511 ("init: Fix Ctrl-C shutdown hangs during wait calls");disconnectIncoming()alone would not have released it.
Classification
The survey's classification of this bug. The explorer charts these fields across all bugs; the method page explains them.
| Field | Value |
|---|---|
| Symptom | hang |
| Worst effect observed | hang |
| Seen in | branch-build |
| Scope | production |
| Trigger | Shutdown (shutdown) |
| Phase | shutdown |
| Side | server |
| Component | bitcoin-init |
| Root cause kind | assumption-violated |
| Fix kind | new-code-path |
| Detection | manual-test |
| Needs a peer that is not libmultiprocess C++ | no |
| Needed a sanitizer to see | no |
| Defect ever on master | yes |
| Released | source |
| Confidence | high |