Bug 32
Destroying a client proxy races with the event loop closing its connection
- Status
- Fixed
- Symptom
- Data race; worst effect seen: sanitizer report only
- Where seen
- Libmultiprocess tests (mptest)
- Trigger
- A client disconnects abruptly, for example because it is killed during a call
- First reported
- 2025-06-27 (first report)
- Fixed
- 2025-07-01
- Fix
- mp#186
- Shipped to Bitcoin Core
- bitcoin#32345
- In released binaries
- No; the code was in source releases only
- Defect ever on master
- Yes
Summary
When a ProxyClient object was destroyed while its connection was being torn down, its cleanup handler called Connection::removeSyncCleanup on the destroying thread. Nothing synchronized that call with the event loop thread, which could be destroying the same Connection at that moment. ThreadSanitizer reported it as a data race in the mptest "disconnecting and blocking during the call" test. With different timing it could have crashed, but no crash was attributed to it. Fixed in mp#186 by making the call on the event loop thread. The code was in Bitcoin Core source releases v22 through v29, which did not ship IPC binaries.
What happened
mp#160 added the first tests that break a connection while a server call is running. Within days they failed intermittently in Bitcoin Core CI, on bitcoin#32345 and on Sjors/bitcoin#90. While investigating, ryanofsky ran mptest under ThreadSanitizer; mp#186 ("Fix mptest failures in bitcoin CI") adds a TSan CI job for this. The test passed, but TSan reported:
WARNING: ThreadSanitizer: data race
Write of size 8 ... by thread T10:
#0 mp::EventLoopRef::reset(bool) src/mp/proxy.cpp:61
#1 mp::Connection::~Connection() include/mp/proxy.h:58
against an earlier read by thread T11 in Connection::removeSyncCleanup. No CI failure was attributed to this particular race.
Root cause
The ProxyClientBase constructor in include/mp/proxy-io.h registers a cleanup function that runs when the proxy is destroyed, on whichever thread destroys it. Before the fix it began with
if (m_context.connection) {
m_context.connection->removeSyncCleanup(disconnect_cb);
and only then entered m_context.loop->sync(...) to release the capability. removeSyncCleanup dereferences Connection::m_loop to lock the event loop's mutex.
In the test, the proxy is the server's ProxyClient<Thread> in request_threads, the server's handle for the client thread that made the call. Worker thread T11 destroys it in PassField (include/mp/type-context.h) when the IPC method returns. The method has just called client_disconnect(), so the event-loop thread T10 is in ~Connection destroying the server connection and resetting m_loop. Nothing orders the worker's null check and removeSyncCleanup call against that destructor, so the worker can read a Connection that is being destroyed. The fix's commit message says this "could potentially cause segfaults if the timing was different".
The unsynchronized call dates from the initial commit, where ~ProxyClientBase already called m_connection->removeSyncCleanup(...) before entering m_loop.sync. It was harmless while connections outlived every proxy using them. That held until mp#160's tests broke connections while a server call was running.
The code
Quoted from d8011c83608e^. The ProxyClientBase constructor (include/mp/proxy-io.h) registers a cleanup function that runs when the proxy is destroyed, on whichever thread destroys it. It unregisters the connection's disconnect callback before switching to the event-loop thread:
m_context.cleanup_fns.emplace_front([this, destroy_connection, disconnect_cb]{
if (m_context.connection) {
// Remove disconnect callback so it doesn't run and try to access this
// object after it's already destroyed.
m_context.connection->removeSyncCleanup(disconnect_cb);
// ...
m_context.loop->sync([&]() {
Connection::removeSyncCleanup (src/mp/proxy.cpp) reaches the event loop through the connection's own m_loop reference:
void Connection::removeSyncCleanup(CleanupIt it)
{
const Lock lock(m_loop->m_mutex);
m_sync_cleanup_fns.erase(it);
}
In the "disconnecting and blocking during the call" test the proxy is the server's ProxyClient<Thread> for the caller, which the worker thread destroys when the method returns, just after the method has disconnected the client:
- Worker: the cleanup reads
m_context.connection, still set. - Event loop:
~Connectionfor that connection runs and resets itsm_loopreference. - Worker:
removeSyncCleanupreadsm_loopfrom the connection being destroyed. TSan reports this as a data race; with a later step 3, the connection has been freed.
The fix (d8011c83608e) moves the null check and the removeSyncCleanup call into the sync() lambda (comment lines trimmed):
@@ -420,11 +420,7 @@ ProxyClientBase<Interface, Impl>::ProxyClientBase(typename Interface::Client cli
m_context.cleanup_fns.emplace_front([this, destroy_connection, disconnect_cb]{
- if (m_context.connection) {
- // Remove disconnect callback so it doesn't run and try to access this
- // object after it's already destroyed.
- m_context.connection->removeSyncCleanup(disconnect_cb);
-
+ {
@@ -433,6 +429,14 @@ ProxyClientBase<Interface, Impl>::ProxyClientBase(typename Interface::Client cli
m_context.loop->sync([&]() {
+ if (m_context.connection) m_context.connection->removeSyncCleanup(disconnect_cb);
+
// Release client capability by move-assigning to temporary.
~Connection also runs on the event-loop thread, and its disconnect callback clears m_context.connection, so inside sync() the proxy either sees a live connection and unregisters before ~Connection can start, or sees null and leaves the connection alone.
Fix
The fix commit d8011c83608e ("proxy-io: fix race conditions in ProxyClientBase cleanup handler") moves the removeSyncCleanup call inside the loop->sync() lambda, still guarded by if (m_context.connection), so it runs on the event-loop thread, serialized with the disconnect handler that deletes the Connection (+9/-5 in proxy-io.h). Because ~Connection now runs later relative to the worker, this exposed three further TSan races in the same test, where code run from ~Connection read ProxyClientBase fields without locks; those were fixed separately in the same PR by fe4a188803c6 ("proxy-io: fix race conditions in disconnect callback code"; see its page). ryanofsky later cited this commit on mp#189 ("Races between ~ProxyClient<Thread> and ~Connection destructors") as the pattern for the fix in mp#201, which applied the same rule to ProxyClient<Thread>'s own disconnect callback (its page).
Timeline
- 2019-06-25: unsynchronized call present since 06e1045baba0, the initial commit.
- 2025-06-19: mp#160 merged with the disconnect tests that reach it.
- 2025-06-27: found by ryanofsky with TSan and fixed in mp#186 (opened that day).
- 2025-07-01: fix merged in mp#186.
- 2025-08-18: shipped to Bitcoin Core master in bitcoin#32345 ("ipc: Handle unclean shutdowns better"), which included a subtree update with mp#186.
- Releases: present in the source of v22 through v29; v30.0, the first release with IPC binaries, 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 thread-affinity assertion, a design rule
- Earliest detector ThreadSanitizer (did not exist then)
- Diagnosis moderate
- Fix design easy
- Debugging aids sanitizer output, symbolized stack traces, a reproduction script
- Rust yes
Would Rust have prevented it? Yes. Capnproto-rust RPC objects are Rc-based and !Send, so a worker thread could not hold or destroy the proxy off the event loop thread at all; with a hand-rolled multithreaded design the compiler would force the Connection behind an Arc, which turns the crash into a lifetime extension that can keep the loop alive. More on Rust
Could it have been prevented?
Probably. The fix is the rule: anything that touches a Connection runs on the
event-loop thread, which is the thread that destroys it. The cleanup lambda registered
by ProxyClientBase already entered loop->sync() to release the capability; it just
did the removeSyncCleanup call first, on whichever thread happened to destroy the
proxy. Moving the call inside the sync() block
(d8011c83608e,
+9/-5) is the whole fix. The unsynchronized call dates from the 2019 initial commit,
when connections outlived every proxy and the ordering was true by construction; an
assert(loop->isLoopThread()) in Connection::removeSyncCleanup, of the kind
mp#201 later added to the
same functions, would have failed the first time a proxy was destroyed off the loop,
and would have made the 2019 assumption a stated contract instead of an accident.
Could it have been found sooner?
Yes, by ThreadSanitizer on a test that breaks a connection while a server call is in
flight. The two halves arrived a week apart in June 2025: the disconnect tests in
mp#160, then the library's
TSan job in mp#186 itself,
which ryanofsky added while investigating the intermittent failures those tests caused
in Bitcoin Core CI on
bitcoin#32345.
The six-year-old race reported on the first sanitized run. Before that nothing could
have seen it: Bitcoin Core's TSan job existed from 2024 but ipc_tests never dropped a
connection mid-call, and every real client was a spawned subprocess the node stopped
only after its proxies were gone.
What made it hard to debug?
TSan gave a symbolized pair of stacks (T10 in ~Connection resetting m_loop, T11 in
removeSyncCleanup from the ProxyClient<Thread> destructor in PassField), and a
local sanitized mptest loop reproduced it, so no gdb session or CI archaeology was
needed. The work was in reading the trace: the commit body spends four paragraphs
naming the three threads and explaining why the server's per-request Thread proxy is
destroyed by the worker just as the loop thread destroys the connection, and that
explanation is what later let the same shape be recognized in
mp#189.
The fix also shifted the timing of ~Connection and uncovered three further races in
the same test, so the first TSan report was masking the next ones; the PR had to be
re-run under the sanitizer after each change rather than fixed from one trace.
Lesson
Destructors and cleanup hooks run on whatever thread drops the object; if they touch state owned by an event loop, hop to the loop first and assert the affinity in the callee, so the ordering that holds by construction today is enforced when the lifetimes change.
Classification
The survey's classification of this bug. The explorer charts these fields across all bugs; the method page explains them.
| Field | Value |
|---|---|
| Symptom | data-race |
| Worst effect observed | sanitizer-report-only |
| Seen in | mptest |
| Scope | production |
| Trigger | Client disconnects abruptly (client-unclean-disconnect) |
| Phase | steady-state |
| Side | server |
| Component | proxy-io |
| Root cause kind | lifetime |
| Fix kind | local-fix |
| Detection | tsan |
| Needs a peer that is not libmultiprocess C++ | no |
| Needed a sanitizer to see | yes |
| Defect ever on master | yes |
| Released | source |
| Confidence | high |