Bug 37
Double free when a thread exits while its connection is being destroyed
- Status
- Open, fix under review
- Symptom
- Crash; worst effect seen: crash
- Where seen
- Libmultiprocess tests (mptest)
- Trigger
- The server side goes away abruptly
- First reported
- 2025-07-10 (first report)
- Fixed
- Not yet
- Fix
- mp#361
- In released binaries
- Yes
- Defect ever on master
- Yes
Summary
When a thread that had made IPC calls exited at the same moment its Connection was destroyed on the event-loop thread, both sides could destroy the same ProxyClient<Thread> entry (the handle to the server thread that runs this thread's calls) in the thread's request_threads / callback_threads maps, giving a heap-use-after-free and a glibc "double free or corruption" abort. This is the thread-exit path of the race family described in mp#189; the in-call path was fixed separately in mp#201 (bug page). It was seen as an intermittent CI crash in a new mptest case. Bitcoin Core v29 through v31.1 contain the code, and the fix commit says it is reachable on master through ConnectStream connections when the peer dies while such a thread exits. A fix is in mp#361, which fixes several disconnect bugs found in review, not yet merged.
What happened
- ryanofsky's mp#189 (2025-07-10) described the family: "if the destructors are called from different threads in a race, numerous problems occur". Its Problem 3, a thread exiting and destroying its
ThreadContextwhile the disconnect callback is still in progress, has the same two actors and the same unsynchronized handoff as this bug; the fix does not cite mp#189, so the link is an inference from the mechanism. - The second push of mp#335, which adds methods to close a connection and wait for its calls to finish (2026-08-12), failed CI in the PR's new test "Waiting for in-flight server call to finish after disconnect" with
double free or corruption (out), aborting from__malloc_arena_thread_freeresas a thread exited. ryanofsky, working with Claude, reproduced it under gdb and an ASan build: a read of a freedrequest_threadsmap node inside theProxyClientBase<Thread>disconnect callback, freed moments earlier by theSetThreadcleanup's erase. The push note (2026-08-13) calls it a pre-existing~ThreadContext()bug exposed by the new test together with mp#298, which made each clientProxyClientregister the handler that deletes itsConnectionon disconnect.
Root cause
Every thread that makes IPC calls owns a thread_local ThreadContext (include/mp/proxy-io.h) with maps of ProxyClient<Thread> keyed by Connection*, guarded on paper by Waiter::m_mutex. Two sides can destroy an entry: the owning thread when it exits (the implicit ~ThreadContext), and the event loop when the connection breaks (the SetThread cleanup callback in src/mp/proxy.cpp, registered with addSyncCleanup). ~ThreadContext took no lock, the SetThread callback used a captured map iterator, and the "who destroys it" decision rested on an unlocked m_disconnect_cb.reset() in the callback, which mp#201's fix had moved outside the mutex.
If the exiting thread's ~ProxyClient<Thread> read that flag in the window, it skipped its loop->sync() handshake and went on; the loop thread's erase then destroyed the same object again, left the ProxyClientBase disconnect callback registered, freed the node, and ran that callback on the freed node. The exiting thread finished its own half-run destructor on the same memory, and glibc caught the corruption at thread exit.
Nothing exercised this until mp#335's test made a call thread exit right after the server disconnected, and mp#298 (merged 2026-08-11) made the client Connection be deleted on the loop thread at that same moment.
Fix
No fix merged yet. 3a4a5eb5f54e in mp#361 (co-authored with Claude) makes removal from the map, under Waiter::m_mutex, the single decision point. An explicit ~ThreadContext swaps both maps out under the lock and destroys them after releasing it, since ~ProxyClient<Thread> blocks in EventLoop::sync() and the loop thread locks Waiter::m_mutex itself. The SetThread callback looks the entry up by Connection* under the lock and returns if it is gone. ~ProxyClient<Thread> checks m_context.connection before removeSyncCleanup. A testing_hook_thread_client_destroy hook lets the new test "Thread exiting while its connection is destroyed" interleave the two sides; it fails on every run without the fix. The fix was first pushed to mp#335 and moved into mp#361 on 2026-09-11.
Next steps
- Review and merge mp#361. This fix, 3a4a5eb5f54e, was added on 2026-09-11, after the PR's only ACK, so no reviewer has signed off on it yet. The parts to check are the explicit
~ThreadContext, which swaps the maps out underWaiter::m_mutexand destroys them after unlocking, and theSetThreadcallback's lookup byConnection*. - After it is merged, update Bitcoin Core's libmultiprocess subtree. Bitcoin Core v29 through v31.1 contain the code, and the fix commit says the race is reachable on master through
ConnectStreamconnections. - Update mp#189 after the merge. Its Problem 3 is this bug, and the in-call problems were fixed by mp#201. Close the issue if nothing else in it remains open.
Timeline
- 2024-08-09: introduced by 8ba0d03b4439 (mp#106), which added the
SetThreadcallback that erases entries from the event-loop thread. - 2025-07-10: race family described in mp#189, still open.
- 2025-09-17: mp#201 fixes the in-call path (a deadlock in the disconnect handler) only.
- 2026-08-12: CI crash on mp#335 push 2; fix written the next day.
- 2026-09-11: fix moved into mp#361; open as of 2026-09.
- 2026-09-29: reported independently in a private security review (AR-76), with a test and a fix built on the same approach as mp#361.
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 a design rule, lock annotations
- Earliest detector a disconnect test (did not exist then)
- Diagnosis hard
- Fix design hard
- Debugging aids sanitizer output, a hook to force the timing, lock annotations
- Rust partly
Would Rust have prevented it? Partly. Capnproto-rust capabilities are !Send, so a calling thread could not own proxies that the loop thread erases, ruling out the two-destroyer layout; if the maps were shared through an Arc<Mutex<..>> instead, the unlocked flag read and the double drop would not compile, but the rule to drop entries with the mutex released (the drop blocks on the loop thread) is a deadlock the compiler cannot see. More on Rust
Could it have been prevented?
Probably. The fix states the rule: when two parties may destroy the same entry, removal
from the container under the lock is the single decision, and whoever removes it
destroys it. Before
3a4a5eb5f54e
the decision rested on an unlocked read of m_disconnect_cb, the implicit
~ThreadContext destroyed the maps with no lock at all, and the SetThread callback
dereferenced a captured iterator. Waiter::m_mutex guarded the maps "on paper";
-Wthread-safety annotations on request_threads and callback_threads would have
flagged the unlocked reset that
mp#201 moved outside the
lock, though not the implicit destructor, which the analysis does not see. The second
destroyer came from
8ba0d03b4439
(mp#106), and the question
"the map now has two erasers; which one wins when both run?" was not asked then. It was
asked in mp#189 a year
later, as Problem 3, and answered for the in-call path only.
Could it have been found sooner?
Yes, by a test in which a thread that made IPC calls exits while its Connection is
destroyed on the loop thread, under ASan. It was described in mp#189 (2025-07) but no
test exercised it, and mp#201 fixed the in-call path without adding locking to
~ThreadContext. It surfaced by accident 13 months later, when
mp#335's drain test made a
call thread exit right after a server disconnect and
mp#298, merged the day
before, made the client Connection be deleted on the loop thread at that moment;
push 1 of mp#335 had passed. The fix's testing_hook_thread_client_destroy makes the
interleaving deterministic and the test fails on every run without it; the earlier
TSan and disconnect jobs had no path that ended a calling thread mid-teardown.
What made it hard to debug?
The CI failure was double free or corruption (out) from
__malloc_arena_thread_freeres as a thread exited, a stack that names glibc's
per-thread cleanup and nothing else. ryanofsky, working with Claude, reproduced it
under gdb and an ASan build, which gave the freed request_threads node, the read in
the ProxyClientBase disconnect callback and the freeing erase moments earlier, both
inside one ~Connection; from there the interleaving took five steps to write down,
including the half-run destructor on the exiting thread. The fix was the largest of the
three in mp#361: an explicit
~ThreadContext, lookup by key instead of iterator, a null check on
m_context.connection, and the constraint that entries are destroyed with the mutex
released because ~ProxyClient<Thread> blocks in EventLoop::sync(). Review found
that mp#201's "Synchronization note" stated the lock rule backwards
(enirox001),
corrected in
019b699bc9e7.
Clang lock annotations on the maps, and an ASan job on the disconnect tests, would
have shortened the path.
Lesson
A lock that one of two destroyers does not take protects nothing; give every shared entry a single removal point under the lock, let removal decide who destroys, and write down which locks may be held while blocking on another thread, then test the rule with a hook that forces both sides into the window.
Notes
- mp#201's
ThreadContext"Synchronization note" saidWaiter::m_mutexmust not be locked beforeEventLoop::m_mutex, the reverse of the documented and actual lock order. @enirox001 pointed out the contradiction while reviewing this fix, and mp#361's doc commit 019b699bc9e7 corrects it: the real constraint is that a thread other than the event-loop thread must not holdWaiter::m_mutexwhile callingEventLoop::sync()orpost().
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 | mptest |
| Scope | production |
| Trigger | Server disconnects abruptly (server-unclean-disconnect) |
| Phase | any |
| Side | client |
| Component | thread-map |
| Root cause kind | missing-lock |
| Fix kind | local-fix |
| Detection | review |
| Needs a peer that is not libmultiprocess C++ | no |
| Needed a sanitizer to see | no |
| Defect ever on master | yes |
| Released | binary |
| Confidence | high |