Bug 70
Example program crashes at startup when its event loop exits too early
- Status
- Fixed
- Symptom
- Crash; worst effect seen: crash
- Where seen
- An example program
- Trigger
- Normal use; no special event is needed
- First reported
- 2026-07-22 (first report)
- Fixed
- 2026-07-30
- Fix
- mp#315
Summary
The mpexample sample program crashed intermittently at startup because its main() held no reference to the EventLoop, so the loop could exit between MakeStream and ConnectStream when no Connection yet kept it alive. The defect was in example code and did not affect users or Bitcoin Core, which already holds its own loop reference; it matters because the example is the code new users copy. It was introduced when mp#274 split connection setup into two calls, and fixed in mp#315.
What happened
- After mp#274 merged,
mpexample(theexample/program that spawnsmpprinterandmpcalculator) crashed intermittently on startup on @xyzconstant's macOS machine, sometimes failing them_post_fn == nullptrassertion inEventLoop::~EventLoop, sometimes with a mutex lock failure, depending on timing. xyzconstant described it in the mp#315 description (2026-07-22). - @ViniciusCestarii reproduced the intermittent failure on Linux.
Root cause
EventLoop::loop() returns as soon as the last EventLoopRef is released, and the example's main() held none of its own; the Connection created for each spawned process held the only references. mp#274, which prepared the library for non-Unix platforms, (38312ad1912f, "Change ConnectStream and ServeStream to accept stream objects") split connection setup into MakeStream(loop, fd) followed by ConnectStream(loop, stream), and example.cpp calls them back to back.
ryanofsky's review explains the window: MakeStream calls loop.sync(), and sync() holds its own EventLoopRef for the duration. When the first MakeStream sync returns, no Connection exists yet and main() holds no reference, so the count drops to zero, the loop wakes, sees done() and exits, destroying the EventLoop while the main thread goes on to post the ConnectStream work to it. Before mp#274, stream creation and connecting happened in one sync() call, so the Connection and its reference existed before the loop next checked done().
Fix
3a997e113cf7 adds mp::EventLoopRef loop_ref{*loop} in main() after the loop thread starts and loop_ref.reset() before joining it (+3), the same pattern Bitcoin Core uses with m_loop_ref in CapnpProtocol. xyzconstant's first version threaded a reference through Spawn; ryanofsky and ViniciusCestarii both suggested the shorter form that merged. A second commit, 36c6c63520e2, documents the reference-counted loop lifetime in EventLoopRef's comment and doc/usage.md.
Timeline
- 2026-07-17: introduced by 38312ad1912f, merged in mp#274.
- 2026-07-22: crash described in mp#315.
- 2026-07-30: fix merged in mp#315.
- Never in Bitcoin Core: the example is not part of the subtree build.
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: A reference count that drops to zero between users
- Prevented by a different API shape, a written spec or documentation
- Earliest detector startup test (did not exist then)
- Diagnosis moderate
- Fix design easy
- Debugging aids a reproduction script, a clear assertion message
- Rust partly
Would Rust have prevented it? Partly. Main would hold an Arc<EventLoop> so the loop thread could not free the object under it, but the client count that makes loop() return is application logic, so the same gap turns the use-after-free into a sync() posted to a loop that has already returned, a hang. More on Rust
Could it have been prevented?
Yes, in two ways the project has since adopted one of. The example's main() ran the
loop on a thread and then used it through a raw EventLoop*, relying on each
Connection to hold the only EventLoopRef; that worked only while nothing between two
connections let the count reach zero, and
38312ad1912f
(mp#274) broke the accident by turning one sync() into two. An API in which
MakeStream and ConnectStream take an EventLoopRef& rather than an EventLoop&,
or in which the helper that starts the loop thread hands back a ref, would have made
the example unable to compile without holding one. The cheaper measure is the one
taken: 36c6c63520e2
documents that the loop exits when its last reference goes, so anyone copying the
example knows the rule Bitcoin Core's CapnpProtocol already followed with
m_loop_ref. The example is the code new users copy, which is why an unsafe idiom
there matters more than its three-line fix suggests.
Could it have been found sooner?
Yes, by running mpexample in CI. The library's CI builds the mpexamples target but
runs only ctest, which does not include the example, so the crash that xyzconstant
hit on macOS five days after mp#274 merged had no chance to show on the PR. The race
is a real interleaving between the main thread and the loop thread and reproduced
intermittently on Linux for ViniciusCestarii, so a single run would not be reliable;
a looped run under TSan, which the library had for mptest since 2025, would have
flagged the loop thread destroying the EventLoop under the main thread's next
post() on most attempts. A sync() from a thread that holds no reference could
also assert, since it is the only way to reach this state.
What made it hard to debug?
The symptoms varied with timing: sometimes the m_post_fn == nullptr assertion in
~EventLoop, sometimes a mutex lock failure, so the first description in
mp#315 was of a startup
crash rather than a cause, and the first fix threaded a reference through Spawn. The
mechanism was settled by ryanofsky's
review,
which needed the detail that sync() holds its own EventLoopRef and makes the loop
re-check done() on every call, so the first MakeStream sync returning with no
Connection alive is exactly the moment the count reaches zero. That is knowledge of
the loop's internals, not of the example; a debug log line when loop() exits, saying
why, would have named it from the first crash.
Lesson
A resource kept alive by whoever happens to be using it dies in the gap between two users; the thread that drives a reference-counted service should hold its own reference for as long as it intends to use the service, and the API should make that hard to forget.
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, assert |
| Seen in | example |
| Scope | example |
| Trigger | Normal use (ordinary-path) |
| Phase | startup |
| Side | client |
| Component | other |
| Root cause kind | refcount |
| Fix kind | local-fix |
| Detection | manual-test |
| Needs a peer that is not libmultiprocess C++ | no |
| Needed a sanitizer to see | no |
| Confidence | high |