Bug 34
Unlocked cleanup state races when a client disconnects as a call returns
- 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 client disconnected while a server worker thread was finishing an IPC call, the event loop thread, destroying the Connection, read and wrote state shared with the worker without holding the locks the worker used. ThreadSanitizer reported three data races in the mptest test "Calling IPC method, disconnecting and blocking during the call". The unlocked list walk and pointer accesses could in principle crash, but no crash was attributed to them. Fixed in mp#186, a PR that fixed several mptest failures seen in Bitcoin Core CI, by taking the missing locks. The code was in Bitcoin Core source releases v22 through v29, which did not ship IPC binaries.
What happened
- In the same PR, ryanofsky fixed a related race in the same test with commit d8011c83608e (bug page). That commit moved a cleanup call from the worker onto the event loop thread, which made
~Connectionrun later relative to the worker. The next local TSan run ofmptestthen produced three new reports, all between the event loop thread destroying the serverConnectionand the worker thread in~ProxyClient<Thread>: 1. the disconnect callback writingProxyClientBase::m_context.connection = nullptr(proxy-io.h:401) against the worker reading it; 2.~Connectionpoppingm_sync_cleanup_fns(proxy.cpp:137) against the worker'sremoveSyncCleanuperase; 3.EventLoopRef::reset(proxy.cpp:61) nullingConnection::m_loopagainst the worker's earlier dereference inremoveSyncCleanup. - ryanofsky's push note on mp#186 says he broadened the fix so "I think it will cover" a TSan failure that Bitcoin Core's CI reported on @Sjors's fork PR Sjors/bitcoin#90, whose CI also runs the libmultiprocess tests (report). That attribution was not confirmed.
Root cause
A server worker thread keeps a ProxyClient<Thread> for each connection in its request_threads map, so that callbacks to the client run on the right client thread. The worker destroys that entry as the IPC call returns, while the event loop thread destroys the server Connection for the client's disconnect. Three pieces of state shared between them had no consistent lock:
Connection::~Connection(src/mp/proxy.cpp) ran and poppedm_sync_cleanup_fnswith no lock, althoughaddSyncCleanupandremoveSyncCleanupmodify the same list underm_loop->m_mutex.- The disconnect callback registered by the
ProxyClientBaseconstructor (include/mp/proxy-io.h) setm_context.connection = nullptrwithout a lock while the worker read the pointer. SetThread's connection-cleanup callback resetm_disconnect_cbbefore taking theWaitermutex, while~ProxyClient<Thread>checksm_disconnect_cbunder that mutex to decide whether to callremoveSyncCleanup.
The commit message explains that the third report is not a problem by itself, since the destructor skips removeSyncCleanup when m_disconnect_cb is null, but TSan reports it correctly because the flag it depends on was accessed without synchronization.
The first two unlocked accesses date from the initial commit. The third dates from 8ba0d03b4439, part of mp#106 ("Clean up ThreadContext pointers when Connection is destroyed", 2024-08-09), which added the SetThread callback. None mattered until mp#160 (merged 2025-06-19) added tests that break connections while a server call is running.
The code
Quoted from fe4a188803c6^. On the worker thread, ProxyClient<Thread>::~ProxyClient (src/mp/proxy.cpp) runs while PassField holds Waiter::m_mutex to erase its request_threads entry. It unregisters the disconnect callback if it is still set:
if (m_disconnect_cb) {
m_context.connection->removeSyncCleanup(*m_disconnect_cb);
}
At the same time the event-loop thread can be in Connection::~Connection (src/mp/proxy.cpp), running the connection's callbacks without any lock:
while (!m_sync_cleanup_fns.empty()) {
m_sync_cleanup_fns.front()();
m_sync_cleanup_fns.pop_front();
}
Two of those callbacks write the fields the destructor above reads. The one from SetThread (src/mp/proxy.cpp) clears m_disconnect_cb before taking the Waiter mutex (mutex), and the one from the ProxyClientBase constructor (include/mp/proxy-io.h) clears the connection pointer with no lock:
thread->second.m_disconnect_cb.reset();
// Remove connection pointer about to be destroyed from the map
const std::unique_lock<std::mutex> lock(mutex);
threads.erase(thread);
// ...
m_context.connection = nullptr;
So the worker reads m_context.connection and m_disconnect_cb while the event loop writes them, and removeSyncCleanup erases from m_sync_cleanup_fns under EventLoop::m_mutex while ~Connection walks the same list unlocked. TSan reported all three in the "disconnecting and blocking during the call" test. The m_disconnect_cb race matters because it decides whether the worker calls into a Connection that is being destroyed.
The fix (fe4a188803c6) puts each access under the lock the other side already uses (the comment added to m_disconnect_cb is omitted):
@@ -405,6 +407,7 @@ ProxyClientBase<Interface, Impl>::ProxyClientBase(typename Interface::Client cli
+ Lock lock{m_context.loop->m_mutex};
m_context.connection = nullptr;
@@ -132,9 +132,11 @@ Connection::~Connection()
+ Lock lock{m_loop->m_mutex};
while (!m_sync_cleanup_fns.empty()) {
- m_sync_cleanup_fns.front()();
- m_sync_cleanup_fns.pop_front();
+ CleanupList fn;
+ fn.splice(fn.begin(), m_sync_cleanup_fns, m_sync_cleanup_fns.begin());
+ Unlock(lock, fn.front());
}
@@ -320,9 +322,9 @@ std::tuple<ConnThread, bool> SetThread(ConnThreads& threads, std::mutex& mutex,
- thread->second.m_disconnect_cb.reset();
// Remove connection pointer about to be destroyed from the map
const std::unique_lock<std::mutex> lock(mutex);
+ thread->second.m_disconnect_cb.reset();
threads.erase(thread);
m_disconnect_cb is now only read and written under Waiter::m_mutex, and m_sync_cleanup_fns and m_context.connection only under EventLoop::m_mutex, so each read in ~ProxyClient<Thread> sees either the value before the disconnect callback ran or after it finished. ~Connection also now waits for a removeSyncCleanup call already in progress, because it needs the same mutex to take the next callback.
Fix
The fix commit fe4a188803c6 (+8/-3):
~Connectionholdsm_loop->m_mutexand splices each callback out ofm_sync_cleanup_fnsinto a local list, running it with the lock released (Unlock(lock, fn.front())). The commit message notes that the fix for the third race depends on this, which makes~Connectionwait for a concurrentremoveSyncCleanup()to finish.- The
ProxyClientBasedisconnect callback takesm_context.loop->m_mutexaroundm_context.connection = nullptr. - The
SetThreadcallback resetsm_disconnect_cbunder theWaitermutex, and a comment saysm_disconnect_cbmust always be guarded byWaiter::m_mutex.
A remaining race between ~ProxyClient<Thread> and the disconnect callback was reported in mp#189 and fixed separately in mp#201 (bug page), by moving the whole erase onto the event loop thread rather than adding locks.
Timeline
- 2019-06-25: unlocked
m_sync_cleanup_fnswalk andconnectionstore present since 06e1045baba0, the initial commit; 2024-08-09: unlockedm_disconnect_cbreset added by 8ba0d03b4439 (mp#106). - 2025-06-19: mp#160 merged with the disconnect tests that reach it.
- 2025-06-27: found by ryanofsky with TSan while working on mp#186.
- 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).
- 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 Locks, waits and wakeups: A field locked by some accessors and not others
- Prevented by lock annotations, a design rule
- Earliest detector ThreadSanitizer (did not exist then)
- Diagnosis moderate
- Fix design moderate
- Debugging aids sanitizer output, a reproduction script
- Rust yes
Would Rust have prevented it? Yes. Fields read on a worker thread and written on the event-loop thread would have to be inside a Mutex (or be atomics) for the types to be Sync, so each of the three TSan races is a compile error; which lock, and the rule that the destructor must wait for removeSyncCleanup, remain design decisions. More on Rust
Could it have been prevented?
Largely. Each of the three races is a field that one side accessed under a lock and the
other side did not: removeSyncCleanup erased from m_sync_cleanup_fns under
EventLoop::m_mutex while ~Connection walked it bare; ~ProxyClient<Thread> read
m_disconnect_cb under Waiter::m_mutex while the SetThread callback reset it before
taking that mutex; m_context.connection was read on a worker and nulled on the loop
thread with no lock at all. Clang -Wthread-safety annotations (GUARDED_BY on those
three members) turn every one of these into a compile error, which is what Sjors
asked for
in review and what mp#201
added afterwards. Two of the sites date from the 2019 initial commit, when the library had
no lock discipline written down; the third was added in 2024 by
mp#106 into code that still
had none, so the missing rule, not any single commit, is the cause.
Could it have been found sooner?
Yes, by TSan on a test that disconnects a client while a server call is in flight. Neither
half existed until June 2025: the disconnect tests arrived in
mp#160 and the library's TSan
job in mp#186 itself, so the
six-year-old races surfaced within eight days of the tests and on the first sanitizer run.
Bitcoin Core's TSan job existed since 2024 but ipc_tests never broke a connection
mid-call, so it had nothing to see; the fork CI report that did appear in the same week
came from the new mptest cases running under Core's job.
What made it hard to debug?
Moderately hard, because the three reports arrived only after the first fix in the same
PR (d8011c83608e)
shifted the timing of ~Connection, and one of them (the m_loop reset) is benign on its
own and only reportable because the flag it depends on was racy, which the commit
message had to work out. TSan gave symbolized traces for both threads, and a local
build-tsan loop of mptest reproduced them, so no gdb session was needed. The fix
needed a lock hierarchy (a companion commit documents that Waiter::m_mutex may be taken
before EventLoop::m_mutex but not the reverse) and the splice-then-Unlock shape in
~Connection so callbacks run without the mutex; the case the locks did not cover was
reported ten days later as mp#189.
Lesson
A lock protects a field only if every accessor takes it; when one side of a shared structure is locked and the other is not, the locked side is documentation, not protection. Annotate guarded fields so the compiler finds the unlocked side, and run TSan on a test that ends a connection while work is in progress before trusting any teardown path.
Notes
- @Sjors asked for thread-safety annotations to enforce the new locking rule; they were added later in mp#201.
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 | missing-lock |
| 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 |