Bug 50
Thread-busy test copies capabilities off the event loop thread
- Status
- Fixed
- Symptom
- Data race; worst effect seen: sanitizer report only
- Where seen
- Libmultiprocess tests (mptest)
- Trigger
- Normal use; no special event is needed
- First reported
- 2025-10-02 (first report)
- Fixed
- 2025-10-07
- Fix
- mp#222
- Shipped to Bitcoin Core
- bitcoin#33518, bitcoin#33519
Summary
The mptest test "Make simultaneous IPC callbacks with same request_thread and callback_thread", added in mp#214 (which fixed a crash on simultaneous IPC calls to one thread), copied Thread::Client capabilities on the main test thread. That raced the event loop thread on Cap'n Proto's non-atomic reference counts. Bitcoin Core's TSan CI job reported it, and it blocked the libmultiprocess subtree updates for master and 30.x until fixed. The defect was in test code and did not affect users. Fixed in mp#222 ("test, ci: Fix threadsanitizer errors in mptest").
What happened
- The subtree update bitcoin#33518, which carried mp#214 into Bitcoin Core, failed Bitcoin Core's TSan job in
mptestwithThreadSanitizer: data race ... in kj::Refcounted::disposeImpl(void*) const(@maflcko, 2025-10-02). The same failure appeared on the 30.x backport bitcoin#33519. - ryanofsky reproduced it in libmultiprocess CI once mp#222 built Cap'n Proto and libc++ with TSan. @Sjors and @Eunovo reproduced it and confirmed the fix while reviewing mp#222 (1, 2).
- A segfault of the same test in the gnu32 CI job of mp#218 ("Better error and log messages") the next day was first attributed to this race, then traced to a separate
kj::strcrash (bug page).
Root cause
The test, added by 1238170f68e8, declared std::optional<Thread::Client> callback_thread, request_thread; and copied the capabilities out of g_thread_context.callback_threads and request_threads on the main test thread, under Waiter::m_mutex. Copying a Thread::Client increments a kj::Refcounted count and destroying the copies at the end of the test decrements it, both on the main thread, while the event loop thread changes the same counts as capabilities are exported and released. KJ reference counts are not atomic, and Cap'n Proto objects may only be used on the event loop thread; the Waiter mutex does not protect them.
The test was reviewed without a TSan run that could see it: libmultiprocess's own sanitize job did not instrument Cap'n Proto or libc++, so races inside KJ were invisible there, and the test reached Bitcoin Core's fully instrumented TSan job only through the subtree update.
The code
The test "Make simultaneous IPC callbacks with same request_thread and callback_thread" in test/mp/test/test.cpp, quoted from 73d22ba2e930^. After one call, the main test thread copies the two Thread capabilities that the call set up:
setup.server->m_impl->m_fn = [&] {};
foo->callFnAsync();
ThreadContext& tc{g_thread_context};
std::optional<Thread::Client> callback_thread, request_thread;
{
Lock lock(tc.waiter->m_mutex);
callback_thread = tc.callback_threads.at(foo->m_context.connection)->m_client;
request_thread = tc.request_threads.at(foo->m_context.connection)->m_client;
}
Copying a Thread::Client increments the reference count of the underlying Cap'n Proto object, and destroying the copies at the end of the test decrements it. KJ reference counts are not atomic and are meant to be used only on the event-loop thread. Waiter::m_mutex does not help, because the event loop never takes it to change these counts:
- Main thread:
callFnAsync()returns once the response arrives. - Event loop: still releasing the capabilities the call exported, it decrements a count in
kj::Refcounted::disposeImpl. - Main thread: copying
m_clientincrements the same count inkj::Refcounted::addRefInternal. TSan reports the two writes as a data race; a lost update would leave the count wrong.
The fix (73d22ba2e930) keeps pointers instead of copies and takes them on the event-loop thread:
@@ -321,12 +321,12 @@ KJ_TEST("Make simultaneous IPC callbacks with same request_thread and callback_t
ThreadContext& tc{g_thread_context};
- std::optional<Thread::Client> callback_thread, request_thread;
- {
+ Thread::Client *callback_thread, *request_thread;
+ foo->m_context.loop->sync([&] {
Lock lock(tc.waiter->m_mutex);
- callback_thread = tc.callback_threads.at(foo->m_context.connection)->m_client;
- request_thread = tc.request_threads.at(foo->m_context.connection)->m_client;
- }
+ callback_thread = &tc.callback_threads.at(foo->m_context.connection)->m_client;
+ request_thread = &tc.request_threads.at(foo->m_context.connection)->m_client;
+ });
Taking a pointer does not touch the reference count, and the test only dereferences the pointers later inside another loop->sync() call, so the counts of these two capabilities are changed only on the event-loop thread.
Fix
The fix commit 73d22ba2e930 keeps plain pointers to the m_client members instead of copies and takes them inside foo->m_context.loop->sync(), so the two thread capabilities are no longer copied or destroyed off the event loop thread (+5/-5 in test/mp/test/test.cpp). The test still copies one capability on the main thread, auto client{foo->m_client};, which the fix leaves alone. mp#222 also makes the libmultiprocess sanitize job use TSan-instrumented Cap'n Proto and libc++, so races of this kind show up in libmultiprocess CI before a subtree update.
@theuni asked whether thread-safety annotations could enforce the loop-thread rule; ryanofsky sketched a LoopObj<T> wrapper for it, which was not implemented.
Timeline
- 2025-10-02: introduced by 1238170f68e8, merged in mp#214.
- 2025-10-02: reported by @maflcko on bitcoin#33518.
- 2025-10-07: fix merged in mp#222.
- 2025-10-10: shipped to Bitcoin Core master in the subtree update bitcoin#33518; 2025-10-16: to the 30.x branch in the subtree update bitcoin#33519.
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 Work or cleanup on the wrong thread: A single-thread object used from another thread
- Prevented by the type system, a thread-affinity assertion
- Earliest detector ThreadSanitizer (did not exist then)
- Diagnosis moderate
- Fix design easy
- Debugging aids sanitizer output, a reproduction script
- Rust yes
Would Rust have prevented it? Yes. A non-atomic reference count is Rc, which is neither Send nor Sync, so copying or dropping a capability on the test thread while the event loop owns it is a compile error; capnp-rust clients are Rc-based for exactly this reason. More on Rust
Could it have been prevented?
Yes, by making the rule the compiler's business. Cap'n Proto capabilities carry
non-atomic KJ refcounts and may only be copied or destroyed on the event-loop thread;
the library follows that rule through EventLoop::sync but nothing enforces it, so a
test could copy two Thread::Clients on the main thread under a mutex the loop never
takes. theuni asked
whether thread-safety annotations could cover this, and ryanofsky
sketched
a LoopObj<T> wrapper that only dereferences given a loop-thread token; neither was
built. Short of a type, an assert(loop->isLoopThread()) in the places the library
hands out capabilities would have fired on the first run of the new test. The fix
(73d22ba2e930)
applies the rule by hand: pointers instead of copies, taken inside loop->sync(), and
it leaves one more main-thread copy (auto client{foo->m_client}) untouched, which
shows how easy the rule is to miss without help.
Could it have been found sooner?
Yes, by the library's own TSan job on the day the test was written, had that job
instrumented Cap'n Proto. It did not: the sanitize job built KJ and libc++ without TSan,
so a race inside kj::Refcounted was invisible there, and the test passed review
(mp#214) and reached Bitcoin
Core's fully instrumented job only via the subtree update, where it failed on the same
day and blocked both the master and 30.x updates. mp#222
closed the gap by building Cap'n Proto and libc++ with TSan in the library's CI.
What made it hard to debug?
The trace itself was clear (two writes to one refcount, one from disposeImpl on a
worker and one from the main thread's copy), and Sjors and Eunovo reproduced both the
failure and the fix within days. The cost was a false attribution: a gnu32 segfault in
the same test the next day was
blamed
on this race for a few hours before a gdb session traced it to an unrelated kj::str
problem. A fresh TSan finding in a test makes every other failure of that test look
explained; the discipline is to confirm each symptom separately.
Lesson
When a library's objects are bound to one thread, a comment and a convention are not enough for code written by people who did not write the convention; give the rule a type or an assert. And a sanitizer job is only as deep as its instrumentation: races inside an uninstrumented dependency do not exist as far as that job can tell.
Classification
The survey's classification of this bug. The explorer charts these fields across all bugs; the method page explains them.
| Field | Value |
|---|---|
| Symptom | data-race |
| Worst effect observed | sanitizer-report-only |
| Seen in | mptest |
| Scope | test-code |
| Trigger | Normal use (ordinary-path) |
| Phase | steady-state |
| Side | n/a |
| Component | test-harness |
| Root cause kind | api-misuse |
| Fix kind | test-only |
| Detection | tsan |
| Needs a peer that is not libmultiprocess C++ | no |
| Needed a sanitizer to see | yes |
| Confidence | high |