Bug 65
Unit test hangs on a missed wakeup when three calls share one thread
- Status
- Fixed
- Symptom
- Hang; worst effect seen: hang
- Where seen
- Libmultiprocess tests (mptest)
- Trigger
- Several calls running at the same time
- First reported
- 2025-12-05 (first report)
- Fixed
- 2026-06-11
- Fix
- mp#295
- Shipped to Bitcoin Core
- bitcoin#35661
Summary
mptest occasionally hung forever in its "Make simultaneous IPC calls on single remote thread" case because the test's response handlers notified the Waiter condition variable without holding its mutex, so the main thread could miss the last wakeup. The defect was in test code and did not affect users. It showed up as 2400 s ctest timeouts on slow Bitcoin Core CI runners (Alpine musl, arm32). Fixed in mp#295, which also adds thread-safety annotations so an unlocked notify no longer compiles under clang's analysis.
What happened
- @maflcko first saw the hang on 2025-12-05 while chasing an unrelated i686 segfault (bitcoin#34014): looping
mptestin an Alpine/musl container with all CPUs busy, the binary hung after 5-15 minutes with the main thread parked inWaiter::waitat the end of the test (then named "Make simultaneous IPC calls to trigger 'thread busy' error") and the worker and event-loop threads idle. That issue was closed on 2026-04-05 as "likely fixed" by bitcoin#34804, a subtree update that fixed race conditions on disconnect; the hang came back. - A 2026-02-09
mptest2400 s timeout on the CI run of pull request bitcoin#34418 (comment, @fanquake) was probably the same bug (the last test started matches) but has no backtrace. - On 2026-06-08 fanquake reported the Alpine musl job for pull request bitcoin#35487 timing out (
mptest ***Timeout 2400.10 sec, last line[ TEST ] test.cpp:430: Make simultaneous IPC calls on single remote thread). maflcko opened bitcoin#35491, reproduced it the same way and posted the backtrace, which has the same three-thread shape as the December hang. Further CI timeouts followed on 2026-06-24 and on arm32 on 2026-06-27.
Root cause
The test fires three overlapping calls on the same remote thread and waits with tc.waiter->wait(lock, [&]{ return running == 0; }). Each response handler, running on the event-loop thread, did running -= 1; tc.waiter->m_cv.notify_all(); without taking Waiter::m_mutex. The main thread can evaluate the predicate, see running nonzero, and before it blocks in condition_variable::wait, the handler decrements and notifies. That notification is lost, the predicate is never re-evaluated, and the test waits forever. ryanofsky identified this from the backtrace and proposed locking the mutex before the notify.
The window is a few instructions wide, so it needed a slow, oversubscribed runner and many repetitions to show up.
The code
The test "Make simultaneous IPC calls on single remote thread" in test/mp/test/test.cpp, as of fa35501c4f0a^. Response handlers run on the event-loop thread; the main thread waits on the same condition variable at the end:
std::atomic<size_t> running{3};
foo->m_context.loop->sync([&]
{
for (size_t i = 0; i < running; i++)
{
// ...
foo->m_context.loop->m_task_set->add(request.send().then(
[&running, &tc, i](auto&& results) {
assert(results.getResult() == static_cast<int32_t>(100 * (i+1)));
running -= 1;
tc.waiter->m_cv.notify_all();
}));
}
});
{
Lock lock(tc.waiter->m_mutex);
tc.waiter->wait(lock, [&running] { return running == 0; });
}
running is atomic, but the handler neither decrements nor notifies under Waiter::m_mutex, so nothing stops both happening between the main thread's predicate check and its going to sleep:
- Main thread: holds
m_mutex, evaluates the predicate and seesrunning == 1. - Event loop: the last response arrives; the handler sets
runningto 0 and callsnotify_all(), which no thread is waiting for yet. - Main thread: blocks in
condition_variable::wait. No other notification will come, so the test hangs.
The fix takes the mutex before notifying, and annotates m_cv so clang's thread-safety analysis rejects an unlocked notify (fa35501c4f0a; the matching MP_REQUIRES on Waiter::wait is omitted):
--- a/include/mp/proxy-io.h
+++ b/include/mp/proxy-io.h
@@ -410,7 +410,7 @@ struct Waiter
- std::condition_variable m_cv;
+ std::condition_variable m_cv MP_GUARDED_BY(m_mutex);
--- a/test/mp/test/test.cpp
+++ b/test/mp/test/test.cpp
@@ -510,6 +510,7 @@ KJ_TEST("Make simultaneous IPC calls on single remote thread")
running -= 1;
+ Lock lock(tc.waiter->m_mutex);
tc.waiter->m_cv.notify_all();
The main thread holds m_mutex from its predicate check until it is asleep inside wait(), so a handler that must take the mutex before notifying can only notify before the check, which then sees 0, or after the main thread is waiting, which wakes it.
Fix
fa35501c4f0a (maflcko) adds Lock lock(tc.waiter->m_mutex); before the test's notify_all(). It also annotates Waiter::m_cv with MP_GUARDED_BY(m_mutex) and Waiter::wait with MP_REQUIRES(m_mutex) in include/mp/proxy-io.h, as maflcko proposed, so clang's thread-safety analysis rejects an unlocked notify at compile time. The annotations change no runtime behaviour. In his ACK ryanofsky noted that guarding the condition variable forbids the notify-after-unlock pattern, which was accepted because performance does not matter here.
Timeline
- 2025-10-02: test added by 1238170f68e8 (@Eunovo), merged in mp#214; the unlocked
notify_allis in that diff. mp#240, which added request cancellation, later renamed and rewrote the test, keeping the unlocked notify. - 2025-12-05: first sighting by @maflcko in bitcoin#34014.
- 2026-02-09: probable CI sighting (no backtrace).
- 2026-06-08: CI timeout reported by @fanquake; 2026-06-09: bitcoin#35491 opened.
- 2026-06-11: fix merged in mp#295.
- 2026-07-07: shipped to Bitcoin Core master by the subtree update bitcoin#35661; bitcoin#35491 closed against it.
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 notify sent without the waiter's lock
- Prevented by lock annotations, a design rule
- Earliest detector static analysis (did not exist then)
- Diagnosis moderate
- Fix design easy
- Debugging aids symbolized stack traces, a reproduction script, lock annotations
- Rust no
Would Rust have prevented it? No. Condvar::notify_all takes no guard, so the same unlocked notify compiles and loses the wakeup the same way; capnproto-rust's single-threaded model would have the test await the responses on the loop instead of waking another thread, which sidesteps the bug rather than catching it. More on Rust
Could it have been prevented?
Yes. The rule is the condition-variable one every threading text states: the state
the predicate reads is changed, and the notify is issued, while holding the mutex the
waiter holds, or the waiter can check the predicate, be overtaken by both the change
and the notify, and then sleep on a notification that has already gone by. The test
added by mp#214
(1238170f68e8)
did running -= 1; tc.waiter->m_cv.notify_all(); from the event-loop thread with no
lock, and the rewrite in mp#240
kept it. The Waiter had carried MP_REQUIRES(m_mutex) annotations since
mp#201, but on the mutex
and wait() only; marking m_cv itself MP_GUARDED_BY(m_mutex), which the fix
(fa35501c4f0a)
does, turns an unlocked notify_all into a compile error, at the accepted cost of
forbidding the notify-after-unlock optimisation. The same misunderstanding had come up
in Bitcoin Core before (bitcoin#30967),
which is why maflcko
argued for
the annotation rather than only the lock.
Could it have been found sooner?
By clang's thread-safety analysis from the day the test was written, had the
condition variable been annotated; the analysis already ran on the file. At runtime
only repetition on a slow machine finds a window a few instructions wide: maflcko's
recipe (loop mptest in an Alpine/musl container with every CPU busy) hung it in
5-15 minutes, Bitcoin Core CI hit it perhaps four times in six months on its musl and
arm32 jobs, and the libmultiprocess repository's own CI never did. TSan does not
report a lost wakeup, since running is atomic and nothing is a data race.
What made it hard to debug?
The first sighting in December 2025 was the second half of an
issue opened for an unrelated i686
segfault, came with a backtrace showing the main thread parked in Waiter::wait and
every other thread idle, and was
closed four
months later as "likely fixed" by a subtree update that had nothing to do with it; the
February CI timeout had no backtrace at all. It took the June recurrence and maflcko
reproducing it with the same recipe for ryanofsky to
read the
unlocked notify off the idle-threads backtrace, after which the fix was one line. What
would have helped is treating "every thread idle, one blocked in a condition-variable
wait" as the signature of a lost wakeup rather than of slowness, and not closing a
hang with a backtrace on the strength of an unrelated merge.
Lesson
Change the waited-on state and notify the condition variable under the waiter's mutex, and annotate the condition variable as guarded so the compiler enforces it; a hang whose backtrace shows one thread in a condition-variable wait and every other thread idle is a lost wakeup until proven otherwise.
Notes
- The i686 nightly segfault that bitcoin#34014 was opened for is a different bug; maflcko later pointed to bitcoin#34732 for it.
- The December hang is attributed to this bug from its identical backtrace and because the version of the test that ran then already had the unlocked notify; the other conceivable cause in that version would have logged "Promise already satisfied" before hanging, and nothing was logged.
Classification
The survey's classification of this bug. The explorer charts these fields across all bugs; the method page explains them.
| Field | Value |
|---|---|
| Symptom | hang |
| Worst effect observed | hang |
| Seen in | mptest |
| Scope | test-code |
| Trigger | Concurrent calls (concurrent-calls) |
| Phase | steady-state |
| Side | n/a |
| Component | test-harness |
| Root cause kind | missing-lock |
| Fix kind | test-only |
| Detection | manual-test |
| Needs a peer that is not libmultiprocess C++ | no |
| Needed a sanitizer to see | no |
| Confidence | high |