Found by ThreadSanitizer in a Bitcoin Core bitcoin-node build (libmultiprocess e8de5c7b68, capnproto 1.0.1) running under Antithesis (see here for how use it in Bitcoin Core).
LLM analysis:
Summary
Server method bodies run on a worker thread and call
call_context.getResults()directly from that thread. When the peer disconnects abruptly, capnp'sRpcConnectionState::disconnect()runs on the event loop thread and clobbers the connection state under the worker's feet. Nothing synchronizes the two.Both TSAN reports hit the same
RpcConnectionStateheap block, on the samekj::OneOf<Own<VatNetworkBase::Connection>, Exception> connectionmember:| offset | writer — T2
b-capnp-loop| reader — T27 (IPC worker) | |---|---|---| |+0x38(ptr) |Own::Own(Own&&)|Own::operator->()| |+0x28(tag) |destroyVariant/init<Exception>|OneOf::is<Own<Connection>>()|capnproto-c++-1.0.1
src/capnp/rpc.c++:// writer: RpcConnectionState::disconnect(), :426-427 auto dyingConnection = kj::mv(connection.get<Connected>()); // write +0x38 connection.init<Disconnected>(kj::cp(networkException)); // write +0x28// reader: RpcConnectionState::RpcCallContext::getResults(), :2581-2584 if (redirectResults || !connectionState->connection.is<Connected>()) { // read +0x28 ... } else { auto message = connectionState->connection.get<Connected>()->newOutgoingMessage( // read +0x38A TOCTOU on the connection state: the worker passes
is<Connected>(), the event loop then disconnects, and the worker dereferences a moved-from (null)Own— or takes the raw pointer just beforedyingConnectiondrops the last reference and writes the return message into a freed transport.Abridged stacks:
Write of size 8 at 0x72780002f438 by thread T2: [#0](/bitcoin-core-multiprocess/0/) kj::Own<capnp::_::VatNetworkBase::Connection>::Own(Own&&) [#1](/bitcoin-core-multiprocess/1/) capnp::_::RpcConnectionState::disconnect(kj::Exception&&) rpc.c++ [#2](/bitcoin-core-multiprocess/2/) capnp::_::RpcConnectionState::taskFailed(kj::Exception&&) rpc.c++ [#3](/bitcoin-core-multiprocess/3/) kj::TaskSet::Task::fire() [#5](/bitcoin-core-multiprocess/5/) kj::EventLoop::turn() [#9](/bitcoin-core-multiprocess/9/) kj::Promise<unsigned long>::wait(kj::WaitScope&, kj::SourceLocation) [#10](/bitcoin-core-multiprocess/10/) mp::EventLoop::loop() src/mp/proxy.cpp:247 Previous read of size 8 at 0x72780002f438 by thread T27 (mutexes: write M0): [#0](/bitcoin-core-multiprocess/0/) kj::Own<capnp::_::VatNetworkBase::Connection>::operator->() [#1](/bitcoin-core-multiprocess/1/) capnp::_::RpcConnectionState::RpcCallContext::getResults(kj::Maybe<capnp::MessageSize>) rpc.c++ [#2](/bitcoin-core-multiprocess/2/) capnp::CallContext<Mining::CreateNewBlockParams, Mining::CreateNewBlockResults>::getResults(...) [#3](/bitcoin-core-multiprocess/3/) mp::ServerRet<mp::Accessor<mp::mining_fields::Result, 18>, mp::ServerCall>::invoke<...> ... [#16](/bitcoin-core-multiprocess/16/) mp::Unlock<mp::Lock, kj::Function<void ()>&>(mp::Lock&, kj::Function<void ()>&) include/mp/util.h:216 [#17](/bitcoin-core-multiprocess/17/) mp::Waiter::wait<mp::ProxyServer<mp::ThreadMap>::makeThread(...)::$_0::...> [#20](/bitcoin-core-multiprocess/20/) mp::ProxyServer<mp::ThreadMap>::makeThread(...)::$_0::operator()() constWhy it happens
PassFieldformp.Context(include/mp/type-context.h:74-196) posts the method body to a dedicated worker thread viaProxyServer<Thread>::post. The generatedServerRet::invokethen callscall_context.getResults()on that thread — but capnpRpcCallContextis event-loop-thread-only.The event loop holds no lock while capnp runs.
EventLoop::loop()(src/mp/proxy.cpp:246-247) acquiresm_mutexonly afterwait_stream->read(...).wait(waitScope)returns; the entire kj loop — includingtaskFailed→disconnect()— turns inside thatwait()with the mutex released.The
M0the worker holds is the per-requestcancel_mutex(include/mp/type-context.h:100-101), which is documented (:113-126) to block the event loop thread from freeing the request's params/results structs mid-execution. But it only guards the promise-cancellation path:Connection::~Connection→m_canceler.cancel()→CancelProbedtor →CancelMonitor::m_on_cancel→Lock cancel_lock{cancel_mutex}.On a remote disconnect, capnp's
RpcConnectionState::disconnect()runs first, directly fromtaskFailed, before theonDisconnecthandler destroys theConnection. That path never touchescancel_mutexand never waits for in-flight workers. That's the unprotected window.(TSAN's "created at" stack for
M0points at a different request'spost; expected, sincecancel_mutexis a stack local and the address is reused.)Reproduction
Sequence from the run:
22.618 node: "IPC server: socket connected" (client connects) 23.511 test harness SIGKILLs the client (exit 137) 23.617 node: "CreateNewBlock(): block weight: 804" (worker T27 executing the call) 23.675 TSAN: data raceMinimal recipe: TSAN-instrumented server, client issues a
Mining.createNewBlock(any method whose body is slow enough to still be executing), SIGKILL the client mid-call. The abrupt EOF drivestaskFailed→disconnect()concurrently with the worker'sgetResults().Antithesis' coverage instrumentation (
__sanitizer_cov_trace_pc_guard_internal→sleep_for) lands inside the window, which is why TSAN addsAs if synchronized via sleep— that's what widened the race, not a false positive.Possible fixes
- Route
getParams()/getResults()throughloop.sync()so all capnp access stays on the event loop thread. Correct, but a round-trip per field access.- Copy params into a worker-owned
MessageBuilderbefore dispatch and write results back in a singleloop.sync()after the body returns. One hop per call, and it removes the need for thecancel_mutex/request_canceledhandshake entirely, since the worker would no longer alias anything the event loop can free.- Make the
cancel_mutexhandshake cover capnp's internal disconnect — would need a hook insideRpcConnectionState::disconnect(), i.e. patching capnp.