Bug 44
Node aborts at shutdown while an IPC mining call is still running
- Status
- Open, fix under review
- Symptom
- Assertion failure; worst effect seen: assertion failure
- Where seen
- A production node, Bitcoin Core functional tests
- Trigger
- The process closes its own connection while the peer is still there
- First reported
- 2025-09-14 (first report)
- Fixed
- Not yet
- Fix
- bitcoin#35932, mp#335
- In released binaries
- Yes (v30.0-v31.1)
- Defect ever on master
- Yes
Summary
bitcoin-node can abort on Assert(m_node.chainman) when it shuts down while an IPC mining client has a call in progress. During shutdown the node disconnects its IPC clients and then frees the ChainstateManager. Disconnecting does not stop a server method that is already running on a libmultiprocess worker thread, so that method can call MinerImpl::chainman() after the pointer has been cleared. It was seen with a Stratum v2 client (sv2-tp) when the node was stopped with Ctrl-C, and in an Antithesis run. Released binaries v30.0 through v31.1 have the bug. The fix is in two open PRs, bitcoin#35932 and mp#335.
What happened
- @TheCharlatan pressed Ctrl-C on a
bitcoin-nodebuilt from master with @Sjors'sv2-tpconnected over IPC and got./node/interfaces.cpp:947 chainman: Assertion 'm_node.chainman' failed. Aborted (core dumped)right after "Flushed fee estimates" (bitcoin#33387, 2025-09-14). He could not reproduce it and closed the issue as a likely version mismatch. ryanofsky explained why it was probably a real shutdown race, sketched a fix, and reopened the issue. - @dergoegge reported the same assertion, now at
node/interfaces.cpp:1057 ... MinerImpl::chainman(), from an Antithesis test that drives the mining interface during shutdown (bitcoin#35845, 2026-07-30). The node log linked from the issue showsrequest #46 canceled while executingandMining.checkBlock$Results kj::Exception: Interrupted by disconnectbefore the assert. ryanofsky linked it to bitcoin#33387 and explained why his 2025 sketch was not enough for this case.
Root cause
Shutdown() (src/init.cpp) calls ipc->disconnectIncoming() and then node.chainman.reset(). CapnpProtocol::disconnectIncoming (src/ipc/capnp/protocol.cpp) only removed the incoming mp::Connection objects on the event loop thread. Destroying a connection cancels the KJ promise of every in-flight request ("Interrupted by disconnect"). But a server method that ProxyServer<Thread>::post() has already handed to a worker thread is not interrupted; it runs to completion on that thread. So disconnectIncoming() returned, the shutdown thread cleared m_node.chainman, and a still-running MinerImpl method (in the Antithesis log, checkBlock) called chainman(), which is *Assert(m_node.chainman).
The fix ryanofsky sketched in bitcoin#33387 was to have shutdown wait for a barrier queued on the async cleanup thread with addAsyncCleanup. That covers requests that had not started: their ProxyServer objects are destroyed on disconnect, and their cleanup is queued ahead of the barrier. It does not cover a request that is executing, because Cap'n Proto keeps that request's ProxyServer objects alive until the method returns, so their cleanup is queued after the barrier. The Antithesis log ("canceled while executing") is that second case.
The race needs a call to be in flight when shutdown starts. A C++ client making occasional synchronous calls rarely hits it, but sv2-tp and the Antithesis workload make mining calls continuously from another process.
Fix
No fix is merged yet. The proposed fix, by ryanofsky with Claude, has two parts:
- mp#335 (open) is stacked on mp#361, an open PR that fixes other disconnect bugs. mp#335 adds
Connection::disconnect(), which tears down the Cap'n Proto RPC system and transport without destroying theConnectionobject (d746bb39fa74). It also adds a per-connectionServerObjectTrackerthat counts liveProxyServerobjects, with awaitDrained()method that blocks until the count reaches zero (98d28df63e8e; the commit subject saysConnection::waitDrained(), but in that commit the method is on the tracker, reached throughConnection::tracker()). Because aProxyServeris not destroyed until its outstanding calls finish, a count of zero afterdisconnect()means no server method is still running. Its test "draining in-flight server call after disconnect" simulates the mining crash. - bitcoin#35932 (open) rewrites
disconnectIncoming()(451f5455d6a1). Insidem_loop->sync()it disconnects every incoming connection except the one from the parent process. Then, off the event loop thread, it waits on each connection'swaitDrained(), logging when the wait blocks, and only then returns. The wait cannot happen on the event loop thread: running methods need the event loop to deliver their results, so blocking it would deadlock.
The fix leaves three documented gaps. Destructors of the server objects (m_impl) that run on the async cleanup thread are not waited for. The parent connection, which stays open, is not drained. New connections can still be accepted during shutdown; mp#269, which adds a connection limit to ListenConnections, proposes a listener API for that. Calls waiting in waitTipChanged() or waitNext() need no wait, because Interrupt() wakes them before Shutdown() runs.
Next steps
- Review mp#335, which adds
Connection::disconnect()andwaitDrained(), after its base mp#361. It has a Concept ACK and first-round reviews from @enirox001 and @xyzconstant, who asked for shorter comments and commit messages. ryanofsky said on 2026-09-04 that he was still working on some of the comments. - After mp#335 is merged, update Bitcoin Core's libmultiprocess subtree. Then take bitcoin#35932 out of draft and review it. It is a draft only because it depends on the library change.
- Decide whether the three documented gaps need follow-ups: destructors that run on the async cleanup thread are not waited for, the parent connection is not drained, and new connections can still be accepted during shutdown (the listener API proposed in mp#269).
Timeline
- 2024-06-24:
MinerImpl::chainman(), with itsAssert, added with the mining interface in bitcoin#30200. - 2025-06-19:
Mining.checkBlock, the call in the Antithesis log, added in bitcoin#31981. - 2025-08-18: introduced by 7f65aac78b95 ("ipc: Avoid waiting for clients to disconnect when shutting down") in bitcoin#32345 ("ipc: Handle unclean shutdowns better"), which added the
disconnectIncoming()call beforechainman.reset(). Before it, shutdown waited for IPC clients to disconnect on their own. - 2025-09-14: bitcoin#33387 reported by @TheCharlatan; reopened by ryanofsky on 2025-09-15.
- 2026-07-30: bitcoin#35845 reported by @dergoegge from an Antithesis run.
- 2026-08-07: bitcoin#35932 and mp#335 opened; both still open.
- Releases: v30.0 through v31.1 have the bug.
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: Cancelling work without waiting for it to stop
- Prevented by a design rule, a different API shape
- Earliest detector a disconnect test (did not exist then)
- Diagnosis moderate
- Fix design hard
- Debugging aids logging, thread names
- Rust partly
Would Rust have prevented it? Partly. A worker task could not hold a plain &ChainstateManager past Shutdown, so it would hold an Arc that keeps chainman alive through the in-flight call, turning the abort into a body running on state the node considers shut down; and in capnproto-rust a server method is a future on the loop thread that disconnect drops, so only a body handed to a worker for blocking work can outlive its connection, and that hand-off has the same cancel-versus-join gap waitDrained() closes. More on Rust
Could it have been prevented?
Probably, by a rule the fix now embodies: a shutdown step that stops accepting work
must also wait for work already running before the next step frees what that work
uses. bitcoin#32345 deliberately
stopped waiting for clients to disconnect (to fix a hang) and inserted
disconnectIncoming() before chainman.reset(), treating "connection destroyed,
promises cancelled" as "no server code running". It was not: a method body already
posted to a worker thread runs to completion, and cancellation in libmultiprocess never
promised otherwise. The API shape that would have made this hard to write is one where
the disconnect call itself returns only after in-flight bodies have released their
server objects, which is what waitDrained() adds; without it, every caller of
disconnect has to know the difference between cancel and join. The trap was
knowable in 2025, since the same distinction had already come up for ProxyServer
lifetimes (mp#160), but the
fix that introduced it was itself a response to a shutdown hang, and the two failure
modes pull in opposite directions.
Could it have been found sooner?
Yes, by a test that starts a slow server call, disconnects from the server side, and
then frees the state the call uses; that is the "draining in-flight server call after
disconnect" case mp#335 adds. Nothing like it existed when bitcoin#32345 merged: the
library's disconnect tests (from 2025-06) disconnected from the client side, and no
functional test stopped the node with an IPC call in flight. TheCharlatan hit it 27
days after the merge with sv2-tp, which keeps a mining call in flight almost
always, so any test that shut the node down under a continuously calling client would
have found the first form quickly. The second form, an executing request whose server
objects Cap'n Proto keeps alive past the barrier, was only shown by Antithesis a year
later, and it is the form a barrier-based test would have passed.
What made it hard to debug?
The first report was closed by its reporter as a version mismatch because it did not
reproduce; ryanofsky
reopened it
from reasoning about the shutdown order, not from a trace, and sketched a barrier fix.
That sketch was found insufficient only when the Antithesis log in
bitcoin#35845 showed request #46
canceled while executing followed by the assert, which distinguished the executing
case from the queued one; the log lines added by mp#240 and the b-capnp-loop thread
name made that reading possible. The fix then took the largest design in the ledger
(288 lines, a tracker, a disconnect() that keeps the object, a wait that must run off
the loop thread to avoid deadlock, three documented gaps) and is still open. What would
have helped is a count of live server objects per connection that the node could log at
shutdown, which is what the fix's pendingServerObjects() provides.
Lesson
Cancelling is not joining: after telling peers to stop, wait for the work already running before freeing what it uses, and make the stop API return only when that wait is done, so callers cannot get the order wrong.
Notes
- dergoegge noted the Antithesis test "only started failing a couple days ago". ryanofsky found no recent Bitcoin Core change that would make it more likely and wondered whether Antithesis had expanded its coverage of IPC mining calls during shutdown. The subtree update that landed shortly before, bitcoin#35661 (2026-07-07, which added a thread pool and rewrote
PassField), does not change the path a C++ mining client uses. - A Ctrl-C hang reported in bitcoin#33463 ("
bitcoin-nodeis unkillable after mining IPC connection is established") was first attributed to this bug, but ryanofsky's test of the bitcoin#33387 barrier patch showed it did not fix it. It was a separate bug, a worker thread blocked insidewaitTipChanged, fixed by bitcoin#33511 ("init: Fix Ctrl-C shutdown hangs during wait calls"); see its page.
Classification
The survey's classification of this bug. The explorer charts these fields across all bugs; the method page explains them.
| Field | Value |
|---|---|
| Symptom | assert |
| Worst effect observed | assert |
| Seen in | production-node, functional-test |
| Scope | production |
| Trigger | Process closes its own connection (local-disconnect) |
| Phase | shutdown |
| Side | server |
| Component | bitcoin-ipc |
| Root cause kind | ordering |
| Fix kind | new-code-path |
| Detection | user-report |
| Needs a peer that is not libmultiprocess C++ | no |
| Needed a sanitizer to see | no |
| Defect ever on master | yes |
| Released | binary |
| Confidence | high |