Bug 45
Server deadlocks when a client disconnects just as a call returns
- Status
- Fixed
- Symptom
- Deadlock; worst effect seen: crash
- Where seen
- A production node, libmultiprocess tests (mptest)
- Trigger
- A client disconnects abruptly, for example because it is killed during a call
- First reported
- 2025-07-01 (first report)
- Fixed
- 2025-09-17
- Fix
- mp#201
- Shipped to Bitcoin Core
- bitcoin#33412, bitcoin#33519
- In released binaries
- Yes (v30.0)
- Defect ever on master
- Yes
Summary
An IPC server could deadlock (or, with different timing, crash) when a client disconnected just as a server method was returning, because a worker thread and the event loop thread both tried to destroy the same per-connection ProxyClient<Thread>, the server's handle for the client thread that made the call. It made the mptest "disconnecting and blocking" test hang intermittently in Bitcoin Core CI, and the same lock cycle stopped a v30.0 bitcoin-node from answering IPC calls under a Rust Stratum v2 client with no disconnect involved. Fixed in libmultiprocess by mp#201 and shipped to Bitcoin Core master and the 30.x branch.
What happened
- @Sjors first saw it as a TSan report in the CI of his Bitcoin Core fork PR Sjors/bitcoin#90, which also runs the libmultiprocess tests (2025-07-01). ryanofsky wrote it up as mp#189 ("Races between
~ProxyClient<Thread>and~Connectiondestructors"), reproducing a TSan error within minutes and a deadlock in about 20 minutes by runningmptestin a loop. - After the test reached Bitcoin Core in bitcoin#33241, a subtree update, the ARM unit-test CI job hit it as a 2400 s ctest timeout (bitcoin#33244, @maflcko, 2025-08-23; @TheCharlatan reproduced it after about 50,000 runs) and once as a segfault in
callFnAsyncParams(bitcoin#33277, DrahtBot CI report, 2025-09-02). maflcko asked for the test to be fixed or removed from 30.x, since ctest has no default timeout and a user running the unit tests could hang forever. - @plebhash's Rust Stratum v2 client made a v30.0 release
bitcoin-nodeon testnet4 stop answering IPC calls after about 25 minutes (bitcoin#33647, "createNewBlocknever returns", 2025-10-17). ryanofsky reproduced it in about a minute; the gdb trace shows the same cycle, and a build of the 30.x branch with the fix no longer hung.
Root cause
When a server worker thread runs a call, it records in its thread-local ThreadContext (the request_threads map) a ProxyClient<Thread> for the calling client thread, so that callbacks go back to that thread. Two code paths destroyed this entry for a connection with no ordering between them:
- In
PassField(include/mp/type-context.h), the worker thread'sKJ_DEFERblock lockedWaiter::m_mutexand calledrequest_threads.erase(connection)after the method returned. - On the event loop thread,
~Connectionran them_disconnect_cbcleanup callback thatSetThread(src/mp/proxy.cpp) had registered for the same entry, which also locksWaiter::m_mutexand erases it.
Deadlock: the worker holds Waiter::m_mutex inside the erase, and ~ProxyClient<Thread> calls loop->sync() to remove its cleanup callback, blocking in EventLoop::post until the loop thread is free. The loop thread is inside the disconnect callback, waiting for Waiter::m_mutex (annotated trace). Holding Waiter::m_mutex while blocking on the event loop is the cycle. In the v30.0 hang the second party was Waiter::post instead of the disconnect callback: the loop thread was posting the client's next getBlock request to the same worker (the client sends each new request on the same Context.thread) while the worker was still in the erase.
With other timing, both threads erased the same map node, giving the TSan race and the occasional segfault: erasing through an invalidated m_disconnect_cb iterator and a use-after-free of the ProxyClient<Thread> (problems 1 and 2 in mp#189; problem 3 is covered under Notes).
The code
Quoted from 4a269b21b8c8^. On the worker thread, PassField (include/mp/type-context.h) adds a request_threads entry for the connection and erases it under Waiter::m_mutex after the method returns:
auto [request_thread, inserted]{SetThread(
GuardedRef{thread_context.waiter->m_mutex, request_threads},
server.m_context.connection,
[&] { return context_arg.getCallbackThread(); })};
// ...
KJ_DEFER(if (erase_thread) {
Lock lock(thread_context.waiter->m_mutex);
// ...
request_threads.erase(server.m_context.connection);
});
fn.invoke(server_context, args...);
SetThread (src/mp/proxy.cpp) registers a callback that ~Connection runs on the event-loop thread after a disconnect. It erases the same entry, locking the same mutex (threads.mutex is Waiter::m_mutex):
thread->second->setDisconnectCallback([threads, thread] {
// ...
const Lock lock(threads.mutex);
thread->second->m_disconnect_cb.reset();
threads.ref.erase(thread);
});
Erasing the entry destroys a ProxyClient<Thread>, whose base-class cleanup (ProxyClientBase, include/mp/proxy-io.h) waits for the event-loop thread:
m_context.loop->sync([&]() {
// ...
if (m_context.connection) m_context.connection->removeSyncCleanup(disconnect_cb);
If the client disconnects as the method returns:
- Worker: the
KJ_DEFERlocksWaiter::m_mutex;erase()runs~ProxyClient<Thread>, whose cleanup blocks inloop->sync()until the event-loop thread is free. - Event loop:
~Connectionruns theSetThreadcallback, which blocks onthreads.mutex, held by the worker. Deadlock. (In the v30.0 hang the event loop was instead inWaiter::post, handing the client's next request to the same worker, which also locksWaiter::m_mutex.)
In step 1, before blocking, ~ProxyClient<Thread> also calls removeSyncCleanup(*m_disconnect_cb) on a callback that ~Connection has already spliced out of its cleanup list to run: the invalid-iterator error in mp#189. With other timings the two erasers overlap instead of blocking, giving the TSan race and the segfault.
The fix moves the erase and the callback removal into loop->sync(), on the event-loop thread (4a269b21b8c8). The same commit moves the SetThread call into loop->sync() too; that hunk, comments, asserts and the inlining of setDisconnectCallback are omitted below:
--- a/include/mp/type-context.h
+++ b/include/mp/type-context.h
@@ -89,29 +89,39 @@
KJ_DEFER(if (erase_thread) {
- Lock lock(thread_context.waiter->m_mutex);
- request_threads.erase(server.m_context.connection);
+ server.m_context.loop->sync([&] {
+ ConnThreads::node_type removed;
+ {
+ Lock lock(thread_context.waiter->m_mutex);
+ removed = request_threads.extract(server.m_context.connection);
+ }
+ });
});
--- a/src/mp/proxy.cpp
+++ b/src/mp/proxy.cpp
@@ -338,17 +349,18 @@ ProxyClient<Thread>::~ProxyClient()
if (m_disconnect_cb) {
- m_context.connection->removeSyncCleanup(*m_disconnect_cb);
+ m_context.loop->sync([&]() {
+ if (m_disconnect_cb) {
+ m_context.connection->removeSyncCleanup(*m_disconnect_cb);
+ }
+ });
}
The worker now waits in loop->sync() without holding Waiter::m_mutex, and every add, erase and callback removal for these entries runs on the same thread as ~Connection, so the disconnect callback runs either entirely before the erase, which then finds nothing, or after it, by which time the destructor has unregistered it.
Fix
The fix commit 4a269b21b8c8 moves all creation and destruction of these entries onto the event loop thread. PassField calls SetThread inside server.m_context.loop->sync(), and the deferred erase also runs inside sync(), using request_threads.extract() under Waiter::m_mutex and destroying the node after releasing the mutex. ~ProxyClient<Thread> calls removeSyncCleanup inside loop->sync() and re-checks m_disconnect_cb there, and the SetThread callback resets m_disconnect_cb before taking the mutex. Asserts check that ~Connection, removeSyncCleanup and SetThread run on the loop thread. Other commits in the PR are preparation: std::optional in ConnThreads and try_emplace in SetThread (@Sjors), Waiter::m_mutex thread-safety annotations and GuardedRef, a ThreadContext doc comment, and -Wthread-safety in CI. No new test was added.
Timeline
- 2024-08-09: introduced by 8ba0d03b4439, part of mp#106 ("Clean up ThreadContext pointers when Connection is destroyed"), which added both the worker-side erase and the
SetThreaddisconnect callback; in Bitcoin Core from 2024-09-09. - 2025-06: mp#160 adds the "disconnecting and blocking" test that exposes it.
- 2025-07-01: first report (@Sjors, fork CI TSan); 2025-07-10: mp#189 filed.
- 2025-08-23: bitcoin#33244 (mptest timeout); 2025-09-02: bitcoin#33277 (segfault).
- 2025-09-17: fix merged in mp#201.
- 2025-09-19: shipped to Bitcoin Core master in the subtree update bitcoin#33412.
- 2025-10-16: shipped to the 30.x branch in the subtree update bitcoin#33519, after v30.0, which has the bug.
- 2025-10-17: production sighting on v30.0, bitcoin#33647.
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 thread-affinity assertion, a design rule
- Earliest detector a disconnect test (did not exist then)
- Diagnosis hard
- Debugging aids a hook to force the timing, other, a reproduction script
- Rust partly
Would Rust have prevented it? Partly. The deadlock itself, holding Waiter::m_mutex while blocking on sync(), is a lock-ordering bug Rust does not prevent, but the interleaving that produces it, a worker thread destroying a ProxyClient<Thread> that the event loop also tears down, is ruled out by capnproto-rust's single-threaded (Rc, !Send) capabilities, which is what moving every erase onto the loop thread achieved by convention; the trade-off is a different threading model for blocking calls. More on Rust
Could it have been prevented?
Probably. The fix is the rule: every add, erase and callback removal for a per-connection
entry runs on the event-loop thread, and the commit adds assert(loop->isLoopThread())
style checks to ~Connection, removeSyncCleanup and SetThread. The introducing
commit (8ba0d03b4439, mp#106) created a second eraser on a second thread for state that
until then had one owner; a standing rule "connection-scoped state is mutated only on the
loop thread, asserted" would have made that commit fail its own assert in the disconnect
test the day it was written. The second half of the rule, never block in loop->sync()
while holding Waiter::m_mutex, was only stated after the fix and
the PR note stated it backwards, so it was not knowable as a rule in 2024.
Could it have been found sooner?
Yes, by any test that disconnects a client between a server method returning and its
request_threads entry being erased, run under TSan in a loop. Neither existed when
the code was introduced: the library had no disconnect test until mp#160 (2025-06) and
no TSan job until the same month. Once both existed the race showed within weeks, and
ryanofsky could reproduce the TSan error in minutes by looping mptest. The Bitcoin Core TSan
job existed in 2024 but ipc_tests never disconnected mid-call, so it had nothing to see.
What made it hard to debug?
The report was a TSan trace from a fork's CI, then a 2400 s ctest timeout that
TheCharlatan needed about 50,000 runs to reproduce. The issue (mp#189) lists three
sub-problems, an abandoned fix branch (pr/threadclean.2) and four candidate designs
and ends "there is a bug and I am not sure how to fix it yet"; the correct fix came ten
weeks later, after a gdb capture of the three-thread deadlock. Two things would have
shortened this: a hook to force the interleaving deterministically (ryanofsky wanted one and
deferred it; the test still relies on timing), and -Wthread-safety annotations on
Waiter::m_mutex, which the same PR added afterwards and which make "who holds this
mutex while blocking" a compile-time question. The v30.0 field hang (bitcoin#33647) was
diagnosed in a day because the gdb recipe already existed.
Lesson
When state is owned by one thread, do not add a cleanup path from another thread to fix a leak; move the cleanup to the owning thread and assert the affinity. Any lock held across a blocking hand-off to another thread is a deadlock waiting for the right timing.
Notes
- The race only became reachable in tests once mp#160 added a test that disconnects between a server method returning and its thread entry being erased. mp#186 had then fixed that test's more common failures, leaving this rarer interleaving.
- mp#189 is still open: mp#201 fixed the in-call path, but the thread-exit path of the same family (mp#189 "Problem 3", a use-after-free of the exiting worker's
ThreadContextmutex: a thread's~ThreadContextdestroys its maps while theSetThreaddisconnect callback erases from them) is addressed separately in mp#361, not yet merged (bug 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 | deadlock |
| Worst effect observed | deadlock, crash |
| Seen in | production-node, mptest |
| Scope | production |
| Trigger | Client disconnects abruptly (client-unclean-disconnect) |
| Phase | steady-state |
| Side | server |
| Component | type-context |
| Root cause kind | lock-order |
| Fix kind | local-fix |
| Detection | tsan |
| Needs a peer that is not libmultiprocess C++ | no |
| Needed a sanitizer to see | no |
| Defect ever on master | yes |
| Released | binary |
| Confidence | high |