Bug 20
Server crashes when a client disconnects during a long-running call
- Status
- Fixed
- Symptom
- Crash; worst effect seen: crash
- Where seen
- Branch-build
- Trigger
- A client disconnects abruptly, for example because it is killed during a call
- First reported
- 2024-10-08 (first report)
- Fixed
- 2024-10-16
- Fix
- mp#118
- Shipped to Bitcoin Core
- bitcoin#31105
- In released binaries
- No; the code was in source releases only
- Defect ever on master
- Yes
Summary
If an IPC client disconnected while a server method it had called was still running, the server crashed when that method returned. While a call runs, the server's worker thread keeps an entry in its request_threads map that records which client thread to send callbacks to. The worker thread erased that entry after the call through an iterator saved before the call, but the disconnect had already removed the entry on the event loop thread. @tdb3 hit it every time by pressing Ctrl-C on a mining client blocked in a long wait and then mining a block. Source builds of v22 through v28 with multiprocess enabled have the bug, which became reachable on master once the mining interface had methods that wait a long time. Fixed in mp#118 by erasing the entry by key instead.
What happened
Reviewing bitcoin#31003 ("Add waitFeesChanged() to Mining interface"), @tdb3 ran the bitcoin-mine sample client from bitcoin#30437 ("ipc: add bitcoin-mine test program") against bitcoin-node, pressed Ctrl-C while the client was blocked in waitFeesChanged, then generated a block (2024-10-08). bitcoin-node segfaulted every time, on the worker thread, inside std::_Rb_tree_rebalance_for_erase called from request_threads.erase in the PassField Context overload; the review includes the gdb backtrace. ryanofsky reproduced it from the steps and opened the fix PR, mp#118 ("shutdown bugfix: Prevent segfault in server if connection is broken during long function call"), the same day.
Root cause
The PassField overload for Context arguments (then in include/mp/proxy-types.h, now type-context.h) runs the server method on a worker thread. Before the call it inserts the caller's callbackThread into the worker's request_threads map and keeps the returned iterator, and a KJ_DEFER erases by that iterator after the method returns.
When the client disconnected mid-call, the event-loop thread destroyed the Connection. Its sync cleanup callback erased that same map entry. That callback was registered by SetThread, which mp#106 added two months earlier so that map entries do not outlive their connection (its page). The fix's code comment also names request_threads.clear() in ~ProxyServer<Thread>, but that destructor asserts the thread is idle, so whether it can run during a call is unconfirmed; it is at most a possible second path. The worker's saved iterator then pointed at a freed tree node, and the deferred erase(iterator) corrupted the map and crashed.
The erase-by-iterator dates from the library's initial commit; mp#106 added the cleanup callback that made the entry disappear on a mid-call disconnect. The crash also needed a server method that blocks for a long time while the client goes away, which the mining interface's wait methods were the first to provide.
The code
Quoted from 9d11042772a2^. On the worker thread, the Context overload of PassField (include/mp/proxy-types.h) adds the caller's thread to request_threads, keeps the iterator, and erases by that iterator after the method returns:
auto [request_thread, inserted]{SetThread(
request_threads, thread_context.waiter->m_mutex,
server.m_context.connection,
[&] { return context_arg.getCallbackThread(); })};
// ...
auto erase_thread{inserted ? request_thread : request_threads.end()};
KJ_DEFER(if (erase_thread != request_threads.end()) {
std::unique_lock<std::mutex> lock(thread_context.waiter->m_mutex);
request_threads.erase(erase_thread);
});
fn.invoke(server_context, args...);
SetThread (src/mp/proxy.cpp) also registers a callback that the Connection destructor runs on the event-loop thread, erasing the same entry:
thread->second.setCleanup([&threads, &mutex, thread] {
// ...
thread->second.m_cleanup.reset();
// Remove connection pointer about to be destroyed from the map
std::unique_lock<std::mutex> lock(mutex);
threads.erase(thread);
});
When the client disconnects during a long call such as waitFeesChanged:
- Worker:
SetThreadinserts the entry, andfn.invokeblocks waiting for fees to change. - Event loop: the client goes away,
~Connectionruns the cleanup callback, and the entry is erased and its tree node freed. - Worker: the method returns, and the
KJ_DEFERcallserase(erase_thread)with the iterator to the freed node, corrupting the map;bitcoin-nodesegfaults in_Rb_tree_rebalance_for_erase.
The fix erases by key instead of by iterator (9d11042772a2, the new comments are trimmed):
@@ -152,10 +152,19 @@ auto PassField(Priority<1>, TypeList<>, ServerContext& server_context, const Fn&
- auto erase_thread{inserted ? request_thread : request_threads.end()};
- KJ_DEFER(if (erase_thread != request_threads.end()) {
+ const bool erase_thread{inserted};
+ KJ_DEFER(if (erase_thread) {
std::unique_lock<std::mutex> lock(thread_context.waiter->m_mutex);
- request_threads.erase(erase_thread);
+ request_threads.erase(server.m_context.connection);
});
Erasing by key under the same mutex does nothing if the disconnect callback already removed the entry, so the worker never touches a freed node.
Fix
The fix commit 9d11042772a2 erases by key, request_threads.erase(server.m_context.connection), under the waiter mutex, which does nothing if the entry is already gone, with a comment naming both invalidation paths. It also adds a comment in SetThread explaining why the iterator its own cleanup callback captures stays valid: ~ProxyClient<Thread> unregisters the callback if the entry is removed first (+17/-3).
With this fix applied, the same reproduction exposed a second crash on the same path: a ProxyServer destroyed after its Connection. mp#118 worked around it with a second commit, and mp#160 ("refactor: EventLoop locking cleanups + client disconnect exception") fixed it. It has its own page.
Timeline
- 2019-06-25: erase by saved iterator present since the library's initial commit 06e1045baba0.
- 2024-08-09: mp#106 merged, adding the connection cleanup callback that erases the entry.
- 2024-10-08: reported by @tdb3 in a review of bitcoin#31003; mp#118 opened.
- 2024-10-16: fix merged in mp#118.
- 2024-10-21: shipped to Bitcoin Core in bitcoin#31105 ("Update libmultiprocess library").
- Releases: v28.x and earlier have the bug in source builds with multiprocess enabled; v29.0 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 Work or cleanup on the wrong thread: Cleanup runs on a thread that does not own the state
- Prevented by a design rule, a thread-affinity assertion
- Earliest detector a disconnect test (did not exist then)
- Diagnosis easy
- Fix design easy
- Debugging aids symbolized stack traces, a reproduction script
- Rust yes
Would Rust have prevented it? Yes. Holding an iterator into request_threads across the blocking call while another thread erases from the map is a borrow that outlives its mutex guard, which the compiler refuses, forcing the erase-by-key-under-the-lock shape the fix adopted; and with capnproto-rust's single-threaded (Rc, !Send) capabilities the worker could not keep a map of Thread proxies at all, so both erasers would be the loop thread, at the cost of a different model for blocking method bodies. More on Rust
Could it have been prevented?
Yes, at the point where the second eraser was added. The worker thread saved a map
iterator before a server call that can block for minutes and erased through it
afterwards; that was safe while the worker was the only thread touching
request_threads. mp#106
then registered a connection cleanup callback that erases the same entry from the
event-loop thread, and neither the review of that PR nor the code asked what happened
to iterators the worker was holding. Two rules would each have stopped it: never hold
an iterator across a wait into a container another thread may mutate (erase by key
under the lock, which is the fix,
9d11042772a2),
or give per-thread state one mutating thread and assert it, which is the rule
mp#201 adopted for this
same map a year later. The first was knowable the day mp#106 landed.
Could it have been found sooner?
Yes, by any test that disconnects a client while a server method is blocked, run under
ASan so the erase through the freed tree node is reported rather than corrupting the
map. No such test existed in either repository until the library's first disconnect
tests in mp#160
(2025-06); ipc_tests only ever called short methods on a connection it closed
cleanly. The bug also needed a server call long enough for a person to press Ctrl-C
during it, which the Mining interface's wait methods and the interactive bitcoin-mine
client were the first to provide, two months after mp#106.
What made it hard to debug?
Nothing. tdb3's review
gave repeatable steps and a gdb backtrace ending in _Rb_tree_rebalance_for_erase
under request_threads.erase, ryanofsky reproduced it and opened the fix the same day.
The eight days to merge went to what the fix uncovered: with the erase no longer
crashing, the same reproduction reached a ProxyServer destroyed after its
Connection (mp-160 record),
which mp#118 had to work around in a second commit. A disconnect-mid-call test in the
library would have exposed both in one CI run instead of one manual reproduction at a
time.
Lesson
An iterator or pointer held across a blocking call is a bet that no other thread mutates the container in the meantime; when you add a cleanup path from another thread, look for every such bet, and prefer idempotent erase-by-key under the lock.
Classification
The survey's classification of this bug. The explorer charts these fields across all bugs; the method page explains them.
| Field | Value |
|---|---|
| Symptom | crash |
| Worst effect observed | crash |
| Seen in | branch-build |
| Scope | production |
| Trigger | Client disconnects abruptly (client-unclean-disconnect) |
| Phase | steady-state |
| Side | server |
| Component | type-context |
| Root cause kind | lifetime |
| Fix kind | local-fix |
| 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 |