Bug 24
IPC shutdown locks a mutex after its event loop was destroyed
- Status
- Fixed
- Symptom
- Crash; worst effect seen: crash
- Where seen
- Bitcoin Core unit tests, Bitcoin Core functional tests
- Trigger
- The process closes its own connection while the peer is still there
- First reported
- 2024-04-16 (first report)
- Fixed
- 2025-02-10
- Fix
- mp#159
- Shipped to Bitcoin Core
- bitcoin#31945, bitcoin#31741
- In released binaries
- No; the code was in source releases only
- Defect ever on master
- Yes
Summary
At IPC shutdown, EventLoop::post and EventLoop::removeClient could lock EventLoop::m_mutex again after EventLoop::loop() had returned. Bitcoin Core destroys the EventLoop as soon as loop() returns, so the lock was on a destroyed mutex. Depending on the platform this threw std::system_error: mutex lock failed: Invalid argument (macOS, FreeBSD, NetBSD), tripped a glibc assertion, or hung (Linux). It made ipc_tests and rpc_misc.py fail intermittently in Bitcoin Core CI, and in rpc_misc.py the parent bitcoin-node exited with an uncaught exception after "Shutdown: done". Source builds of v22 through v28 with multiprocess enabled have it. Fixed in mp#159, shipped in v29.0.
What happened
- @hebasto's BSD nightly builds hit
terminate called after throwing an instance of 'std::system_error' what(): Invalid argumentinipc_testson NetBSD (bitcoin#31618, "multiprocess:ipc_testsfail on *BSD", 2025-01-07) and on FreeBSD (2025-01-17), the FreeBSD log ending atIPC client destroy ... ProxyClient<Init>right afterEventLoop::loop bye. - @Sjors reported
rpc_misc.pyfailing on macOS in the CI of bitcoin#30975 ("ci: build multiprocess on most jobs") (mp#128, 2025-01-17): duringstop_node, afterShutdown: doneandEventLoop::loop bye., the parentbitcoin-nodedied withlibc++abi: terminating due to uncaught exception of type std::__1::system_error: mutex lock failed: Invalid argument. ryanofsky read it as a lock on a destroyed mutex; mp#128 was closed on the assumption that mp#129, which fixed a different event loop shutdown race (its page), had fixed it too, and was later identified as this bug. - Sjors saw the same message in
ipc_testson macOS, in CI and locally (mp#154, "Occasional ipc_test failures (macOS?)", 2025-02-06). ryanofsky reproduced it in a macOS VM; the stack trace shows~ProxyClient<Init>->CleanupRun->EventLoop::sync->EventLoop::post->~UnlockGuardterminating. - On Linux the relock hung instead:
ipc_teststimed out after 2508 s (bitcoin#31921, @maflcko, 2025-02-20), This led to the fix being shipped on its own in bitcoin#31945, a libmultiprocess version update, ahead of the v29 feature freeze, instead of waiting for bitcoin#31741, which replaced the external dependency with a subtree. - An earlier failure was most likely the same bug: a single i686
ipc_testsabort withFatal glibc error: pthread_mutex_lock.c:450 ... assertion failed: e != ESRCH || !robust(bitcoin#29889, @maflcko, 2024-04-16), which ryanofsky linked to it in 2025. No stack trace survived, but theipc_testscode of that time destroyed theEventLoopas soon asloop()returned while another thread could still be insidepost(), and that glibc assertion is what locking a destroyed mutex produces.
Root cause
EventLoop::post and EventLoop::removeClient (src/mp/proxy.cpp) used the Unlock() helper to drop m_mutex, write() a wake-up byte to the loop's pipe, and lock m_mutex again. That write can be what lets loop() return. In post, the loop thread could consume the wake-up, see done(), return from loop() and, in Bitcoin Core's CapnpProtocol::startLoop, run m_loop.reset(), destroying the mutex before the posting thread relocked it. removeClient had the same window around its final write when done() became true, though that case was not observed. Locking a destroyed std::mutex is undefined behavior: libc++ returns EINVAL and throws, glibc's robust-mutex path asserts, or the lock hangs.
The path is the parent-side shutdown of a connection, where the local process releases the last client reference itself instead of the peer disconnecting. Only rpc_misc.py (through the echoipc connection) and ipc_tests exercised it, and the window is a few instructions wide. It surfaced once those tests ran on many platforms in the months before libmultiprocess became a subtree of Bitcoin Core: in bitcoin#30975's CI jobs, in the macOS CI of bitcoin#31802, and in hebasto's BSD nightly builds.
The code
Quoted from 48d01bcca76e^. EventLoop::post (src/mp/proxy.cpp) hands a function to the event-loop thread and wakes it with a pipe write, releasing m_mutex around the write with the Unlock() helper, which relocks it when the callback returns:
std::unique_lock<std::mutex> lock(m_mutex);
m_cv.wait(lock, [this] { return m_post_fn == nullptr; });
m_post_fn = &fn;
int post_fd{m_post_fd};
Unlock(lock, [&] {
char buffer = 0;
KJ_SYSCALL(write(post_fd, &buffer, 1));
});
m_cv.wait(lock, [this, &fn] { return m_post_fn != &fn; });
EventLoop::removeClient uses the same pattern for its final wake-up:
m_num_clients -= 1;
if (done(lock)) {
m_cv.notify_all();
int post_fd{m_post_fd};
Unlock(lock, [&] {
char buffer = 0;
KJ_SYSCALL(write(post_fd, &buffer, 1)); // NOLINT(bugprone-suspicious-semicolon)
});
}
Nothing stops EventLoop::loop() from returning between the write and the relock, and Bitcoin Core destroys the EventLoop, mutex included, as soon as loop() returns. In the macOS failure the posting thread was destroying the Init client:
- Client thread:
~ProxyClient<Init>callssync(), which callspost(); it writes the wake-up byte and is delayed beforeUnlock()relocks. - Event loop: runs the posted cleanup, whose
removeClientdrops the last client reference and makesdone()true; the loop exits. - Bitcoin Core's loop thread destroys the
EventLoop. - Client thread:
Unlock()relocks the destroyedm_mutex; libc++ throwssystem_errorand the process terminates.
The removeClient window is the same, but was not observed. The fix (48d01bcca76e) does not relock in removeClient once done, and has post() hold a client reference while it waits (the startAsyncThread change that uses the new return value is omitted):
@@ -225,6 +225,7 @@ void EventLoop::post(const std::function<void()>& fn)
std::unique_lock<std::mutex> lock(m_mutex);
+ addClient(lock);
m_cv.wait(lock, [this] { return m_post_fn == nullptr; });
@@ -233,21 +234,23 @@ void EventLoop::post(const std::function<void()>& fn)
m_cv.wait(lock, [this, &fn] { return m_post_fn != &fn; });
+ removeClient(lock);
}
@@
-void EventLoop::removeClient(std::unique_lock<std::mutex>& lock)
+bool EventLoop::removeClient(std::unique_lock<std::mutex>& lock)
{
m_num_clients -= 1;
if (done(lock)) {
m_cv.notify_all();
int post_fd{m_post_fd};
- Unlock(lock, [&] {
- char buffer = 0;
- KJ_SYSCALL(write(post_fd, &buffer, 1)); // NOLINT(bugprone-suspicious-semicolon)
- });
+ lock.unlock();
+ char buffer = 0;
+ KJ_SYSCALL(write(post_fd, &buffer, 1)); // NOLINT(bugprone-suspicious-semicolon)
+ return true;
}
While post() holds its client reference done() stays false, so the loop cannot exit until post() has relocked and released it; and the removeClient call that makes done() true no longer touches the mutex after the write that lets the loop exit.
Fix
The fix commit 48d01bcca76e, in mp#159 ("bugfix: Do not lock EventLoop::mutex after EventLoop is done"), makes removeClient call lock.unlock() without relocking once the loop is done and return true, so startAsyncThread stops, and has post hold a client reference (addClient/removeClient) while it waits, so loop() cannot finish while a post is in progress (+10/-7). The commit message says there was "not technically a bug here in the libmultiprocess code or API", since the EventLoop was safe as long as callers waited for its methods to return before deleting it, which the original code on the bitcoin#10102 branch did. Later code for unix-socket connections and unit tests deleted it right after loop() returned.
An earlier attempt in Bitcoin Core, bitcoin#31815 ("multiprocess: Lock CapnpProtocol::m_loop with mutex"), covered only the removeClient case, still failed in CI and was closed. mp#160 ("refactor: EventLoop locking cleanups + client disconnect exception") later moved the same logic into an EventLoopRef class.
Timeline
- 2019-06-25: the unlock-write-relock in
postandremoveClientpresent since the library's initial commit 06e1045baba0. - 2024-04-16: most likely first sighting, bitcoin#29889 (i686
ipc_testsglibc assertion). - 2025-01-07: bitcoin#31618 (NetBSD, FreeBSD); 2025-01-17: mp#128 (macOS); 2025-02-06: mp#154 (macOS).
- 2025-02-10: fix merged in mp#159.
- 2025-02-20: bitcoin#31921 (Linux timeout).
- 2025-02-25: shipped to Bitcoin Core in bitcoin#31945 ("depends: Update libmultiprocess library to fix CI failures"); also part of the subtree added by bitcoin#31741 ("multiprocess: Add libmultiprocess git subtree").
- 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 An object outliving what it points to: An object touched after allowing its destruction
- Prevented by a clear ownership model, a design rule
- Earliest detector ThreadSanitizer (did not exist then)
- Diagnosis hard
- Fix design moderate
- Debugging aids symbolized stack traces, logging, a reproduction script
- Rust yes
Would Rust have prevented it? Yes. Post() holds a borrow of the EventLoop for its whole body, so the loop thread could not drop it in the middle; in practice the loop would live in an Arc, which is the reference count the fix added by hand and which delays destruction rather than forbidding the ordering. More on Rust
Could it have been prevented?
Yes, by either of two rules the fix sequence arrived at. EventLoop::post and
removeClient wrote the wake-up byte that can let loop() return and then relocked
m_mutex, a member of an object the caller was free to destroy the moment loop()
returned. The first rule is "never touch this after the action that may end its
life", which is how removeClient was fixed
(48d01bcca76e:
unlock and return without relocking). The second is that a thread inside an
EventLoop method holds a counted reference for the duration, which is how post was
fixed and what mp#160
then made structural with EventLoopRef. The commit message says the library API was
not at fault, since callers were expected to wait for its methods to return before
deleting the loop; that expectation was never written down, and the unix-socket and
unit-test code added in 2024 broke it without anyone noticing. A documented lifetime
contract, or the refcount that makes the contract unnecessary, was the missing piece.
Could it have been found sooner?
Probably, by running ipc_tests under ThreadSanitizer. The relock and the mutex's
destruction are unordered accesses to the same object from two threads, which TSan
reports whether or not the bad interleaving happens on that run, whereas the window
itself is a few instructions wide and needs the loop thread to exit and destroy the
loop between another thread's write() and its relock. Neither repository ran the
library under TSan when the code was written, and Bitcoin Core's ASan job, which did
build ipc_tests from late 2024, only sees the failure when the timing lands. The
test that exercises the path existed from 2021; what it lacked was a tool that
converts a rare interleaving into a deterministic report.
What made it hard to debug?
Five reports over ten months with three different symptoms: a glibc robust-mutex
assertion on i686 (bitcoin#29889,
no stack trace, linked to this bug only a year later), system_error: Invalid
argument on macOS and the BSDs, and a 2508 s hang on Linux
(bitcoin#31921), because locking a
destroyed mutex is undefined behavior and each libc does something different. The
macOS report was first attributed to the Broken pipe race fixed days earlier in
mp#129 and its issue
closed, and the first fix attempt
(bitcoin#31815) guarded the wrong
side, the m_loop.reset() in Bitcoin Core, and still failed. What settled it was a
stack trace
from a macOS VM showing ~ProxyClient<Init> -> sync -> post -> ~UnlockGuard
terminating, with the log line order (EventLoop::loop bye. before IPC client
destroy) placing the destruction first. A symbolized trace from the first failure
would have saved most of the ten months.
Lesson
Once a method has done the thing that lets its object be destroyed, it must not touch the object again, not even to release a lock; if the method has to keep working, hold a counted reference for its whole duration and let the count decide when the object goes.
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, hang |
| Seen in | unit-test, functional-test |
| Scope | production |
| Trigger | Process closes its own connection (local-disconnect) |
| Phase | shutdown |
| Side | eventloop |
| Component | eventloop |
| Root cause kind | lifetime |
| Fix kind | local-fix |
| Detection | ci-intermittent |
| Needs a peer that is not libmultiprocess C++ | no |
| Needed a sanitizer to see | no |
| Defect ever on master | yes |
| Released | source |
| Confidence | high |