Bug 22
Spawned node process aborts at shutdown with a "Broken pipe" error
- Status
- Fixed
- Symptom
- Crash; worst effect seen: crash
- Where seen
- Bitcoin Core functional tests
- Trigger
- A peer disconnects cleanly
- First reported
- 2024-10-24 (first report)
- Fixed
- 2025-01-27
- Fix
- mp#129
- Shipped to Bitcoin Core
- bitcoin#31740
- In released binaries
- No; the code was in source releases only
- Defect ever on master
- Yes
Summary
A bitcoin-node child process spawned over IPC could abort at shutdown with terminate called after throwing an instance of 'kj::ExceptionImpl' what(): mp/proxy.cpp:242: disconnected: write(m_post_fd, &buffer, 1): Broken pipe. Other threads wake the event loop by writing a byte to a pipe, and the loop closes that pipe when it exits. EventLoop::removeClient sometimes wrote an extra wake-up byte before the loop was really done, so the loop could exit and close its pipe just before another thread's final wake-up write. That write then failed with EPIPE and threw an exception nothing caught. It made rpc_misc.py fail intermittently in Bitcoin Core CI. Source builds of v22 through v28 with multiprocess enabled have it. Fixed in mp#129.
What happened
rpc_misc.pyon the i686 multiprocess CI job failed atstop_node, with the child process that theechoipcRPC had spawned printing theBroken pipeexception (bitcoin#31151, @maflcko, 2024-10-24).- It recurred on CentOS in the CI of bitcoin#30975 ("ci: build multiprocess on most jobs"). @Sjors posted it on mp#128, an issue about a different
rpc_misc.pyfailure (2025-01-21). ryanofsky identified it as the bitcoin#31151 bug, which restarted work on that issue. - ryanofsky reproduced it by running
echoipcandstop_nodein a loop, getting a crash every 20 to 30 minutes, and wrote up the interleaving. Building the library with ThreadSanitizer for the first time found the missing lock at once and the write/close race after a 30-minute loop.
Root cause
EventLoop::loop() (src/mp/proxy.cpp) exits when it wakes on its m_wait_fd pipe and sees m_num_clients == 0 && m_async_fns.empty(), closing both pipe ends. EventLoop::removeClient wrote a wake-up byte whenever m_num_clients reached zero, without checking m_async_fns.empty(), so it could write more than once before the loop was really done.
During ~Connection, the destructor thread calls startAsyncThread and then removeClient, which could see zero clients and write one byte. The async cleanup thread then took a client reference for each queued cleanup and released it. If it was preempted between decrementing m_num_clients and its own write(), the loop thread consumed the earlier byte, saw the done condition, closed the pipe, and the async thread's late write() failed with EPIPE. KJ_SYSCALL turned that into an exception nothing caught.
Separately, the loop read m_num_clients, m_async_fns and m_post_fd without holding m_mutex. ryanofsky clarified that only the extra-write race caused the observed failures; the missing lock was a second, theoretical cause fixed in the same commit.
The echoipc test spawns and shuts down a whole bitcoin-node child on each run, the one place under CI load where a process's event loop shut down through this sequence, and bitcoin#30975 ran it on more CI configurations.
The code
Quoted from 0e4f88d3f9c6^, src/mp/proxy.cpp. EventLoop::removeClient wakes the event loop whenever the client count reaches zero, and EventLoop::loop() exits when a wake-up finds no clients and no queued async cleanups:
void EventLoop::removeClient(std::unique_lock<std::mutex>& lock)
{
assert(m_num_clients > 0);
m_num_clients -= 1;
if (m_num_clients == 0) {
m_cv.notify_all();
Unlock(lock, [&] {
char buffer = 0;
KJ_SYSCALL(write(m_post_fd, &buffer, 1)); // NOLINT(bugprone-suspicious-semicolon)
});
}
}
// ...
m_cv.notify_all();
if (m_num_clients == 0 && m_async_fns.empty()) {
// ...
break;
}
}
wait_stream = nullptr;
m_wait_fd = -1;
KJ_SYSCALL(::close(m_post_fd));
~Connection ends with m_loop.startAsyncThread(lock); m_loop.removeClient(lock);, and the async thread wraps each cleanup it runs in addClient(lock) / removeClient(lock). So one disconnect can write two wake-up bytes, and the loop can act on the first while the second write is still pending:
- Event loop, in
~Connection: queues the async cleanups and starts the async thread;removeClientdrops the count to 0 and writes a byte, althoughm_async_fnsis not empty. - Async thread: runs the cleanup,
removeClientdrops the count to 0 again and releases the mutex, then is preempted before itswrite(). - Event loop: reads the byte from step 1, sees no clients and no async functions, leaves the loop and closes the pipe.
- Async thread:
write(m_post_fd, ...)fails withEPIPE, andKJ_SYSCALLthrows "Broken pipe".
The loop also read m_num_clients and m_async_fns without holding m_mutex. The fix (0e4f88d3f9c6) adds a done() check that requires the lock, and uses it in both places (the new done() function and the post_fd copies are omitted):
@@ -233,13 +236,13 @@ void EventLoop::addClient(std::unique_lock<std::mutex>& lock) { m_num_clients +=
void EventLoop::removeClient(std::unique_lock<std::mutex>& lock)
{
- assert(m_num_clients > 0);
m_num_clients -= 1;
- if (m_num_clients == 0) {
+ if (done(lock)) {
m_cv.notify_all();
@@ -188,29 +188,31 @@ void EventLoop::loop()
- m_cv.notify_all();
- if (m_num_clients == 0 && m_async_fns.empty()) {
- log() << "EventLoop::loop done, cancelling event listeners.";
- m_task_set.reset();
- log() << "EventLoop::loop bye.";
+ if (read_bytes != 1) throw std::logic_error("EventLoop wait_stream closed unexpectedly");
+ std::unique_lock<std::mutex> lock(m_mutex);
+ if (m_post_fn) {
+ Unlock(lock, *m_post_fn);
+ m_post_fn = nullptr;
+ m_cv.notify_all();
+ } else if (done(lock)) {
break;
~Connection holds m_mutex while it queues the cleanups and calls removeClient, so done() is false there and no byte is written in step 1. Only the removeClient call that actually finishes the work writes, and the loop cannot exit before reading that byte, so the write always lands on an open pipe.
Fix
The fix commit 0e4f88d3f9c6 in mp#129 ("Fix 'disconnected: write(m_post_fd, &buffer, 1): Broken pipe' EventLoop shutdown races"), co-authored by @vasild, adds EventLoop::done(), which requires the lock, makes removeClient write only when done() is true, has loop() re-check done() under the lock and not exit while a posted function (m_post_fn) is pending, and copies m_post_fd into a local before unlocking (+32/-19). No test was added.
Timeline
- 2019-06-25:
removeClient's unconditional write and the unlocked exit check present since the library's initial commit 06e1045baba0. - 2024-10-24: bitcoin#31151 reported by @maflcko.
- 2025-01-21: CentOS recurrence reported by @Sjors.
- 2025-01-27: fix merged in mp#129.
- 2025-01-29: shipped to Bitcoin Core in bitcoin#31740 ("depends: Update libmultiprocess library before converting to subtree"), closing bitcoin#31151.
- 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 Locks, waits and wakeups: A done signal sent too early
- Prevented by a design rule, lock annotations
- Earliest detector ThreadSanitizer (did not exist then)
- Diagnosis hard
- Fix design moderate
- Debugging aids core dumps, a reproduction script, sanitizer output
- Rust partly
Would Rust have prevented it? Partly. The unlocked read of m_num_clients and m_async_fns would be rejected because they would live inside the Mutex, but the premature done byte written on a partial condition, and the write to a pipe the receiver already closed, are protocol errors that compile fine. More on Rust
Could it have been prevented?
Yes. The loop's exit condition was m_num_clients == 0 && m_async_fns.empty(), but the
wake-up that announced it was written on m_num_clients == 0 alone, so the "done" byte
could be sent while work was still queued and a second byte was owed later; the loop
took the first, closed the pipe, and the second write hit EPIPE. Two rules would
have prevented it. One predicate for "done", evaluated under the lock at every site
that signals or tests it: the fix
(0e4f88d3f9c6)
adds EventLoop::done(lock) and uses it in both removeClient and loop(). And no
reads of shared members outside m_mutex: the loop's exit test was unlocked, which
-Wthread-safety annotations would have rejected at compile time, though ryanofsky
noted
the unlocked read was not the cause of the observed failures. Both were knowable in
2019 and are what a review of the wake-up protocol would have asked: does every signal
mean the condition holds?
Could it have been found sooner?
Yes, with the library built under ThreadSanitizer and its shutdown run in a loop.
ryanofsky reported that -fsanitize=thread, used on the library for the first time
during this fix, flagged the unlocked read immediately and the write/close race after
about thirty minutes. Neither repository ran the library under TSan in 2019, and
Bitcoin Core's TSan job did not build multiprocess until
bitcoin#30975 was in progress.
Without a tool it needed a workload that shut a whole process down through
~Connection hundreds of times, which only the echoipc child under CI load
provided, and then only on i686 and CentOS.
What made it hard to debug?
The failure was intermittent and lived in a spawned child whose logs the test did not
keep. The report sat for three months while the wrong PR was suspected; when a
recurrence restarted it, the reproduction was a stripped-down rpc_misc.py looped for
20 to 30 minutes per crash, and adding a log file to the child made the race
disappear overnight.
What worked was a core dump: the backtrace put the failed write on the async cleanup
thread inside removeClient, and from that ryanofsky
reasoned out
the three-thread interleaving by hand before TSan confirmed it. Core dumps from
spawned children as a CI artifact, and a TSan build of the library, are the two things
that would have shortened this from three months to a day.
Lesson
When threads signal "done" to a loop that tears itself down on receipt, make the signal and the exit test one predicate evaluated under one lock, and never send the signal for a partial condition; each extra wake-up is a chance for the receiver to be gone when the last one arrives.
Notes
- bitcoin#31105 ("Update libmultiprocess library"), merged a few days before the first report, was suspected first; it contained no related 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 | crash |
| Worst effect observed | crash |
| Seen in | functional-test |
| Scope | production |
| Trigger | Clean disconnect (clean-disconnect) |
| Phase | shutdown |
| Side | eventloop |
| Component | eventloop |
| Root cause kind | ordering |
| 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 |