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

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

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