Summary
Server method bodies run on a worker thread and call call_context.getResults() directly from that thread. When the peer disconnects abruptly, capnp's RpcConnectionState::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 RpcConnectionState heap block, on the same kj::OneOf<Own<VatNetworkBase::Connection>, Exception> connection member:
| 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 +0x38
A 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 before dyingConnection drops the last reference and writes the return message into a freed transport.
Abridged stacks:
Write of size 8 at 0x72780002f438 by thread T2:
#0 kj::Own<capnp::_::VatNetworkBase::Connection>::Own(Own&&)
#1 capnp::_::RpcConnectionState::disconnect(kj::Exception&&) rpc.c++
#2 capnp::_::RpcConnectionState::taskFailed(kj::Exception&&) rpc.c++
#3 kj::TaskSet::Task::fire()
#5 kj::EventLoop::turn()
#9 kj::Promise<unsigned long>::wait(kj::WaitScope&, kj::SourceLocation)
#10 mp::EventLoop::loop() src/mp/proxy.cpp:247
Previous read of size 8 at 0x72780002f438 by thread T27 (mutexes: write M0):
#0 kj::Own<capnp::_::VatNetworkBase::Connection>::operator->()
#1 capnp::_::RpcConnectionState::RpcCallContext::getResults(kj::Maybe<capnp::MessageSize>) rpc.c++
#2 capnp::CallContext<Mining::CreateNewBlockParams, Mining::CreateNewBlockResults>::getResults(...)
#3 mp::ServerRet<mp::Accessor<mp::mining_fields::Result, 18>, mp::ServerCall>::invoke<...>
...
#16 mp::Unlock<mp::Lock, kj::Function<void ()>&>(mp::Lock&, kj::Function<void ()>&) include/mp/util.h:216
#17 mp::Waiter::wait<mp::ProxyServer<mp::ThreadMap>::makeThread(...)::$_0::...>
#20 mp::ProxyServer<mp::ThreadMap>::makeThread(...)::$_0::operator()() const
Why it happens
-
PassField for mp.Context (include/mp/type-context.h:74-196) posts the method body to a dedicated worker thread via ProxyServer<Thread>::post. The generated ServerRet::invoke then calls call_context.getResults() on that thread — but capnp RpcCallContext is event-loop-thread-only.
-
The event loop holds no lock while capnp runs. EventLoop::loop() (src/mp/proxy.cpp:246-247) acquires m_mutex only after wait_stream->read(...).wait(waitScope) returns; the entire kj loop — including taskFailed → disconnect() — turns inside that wait() with the mutex released.
-
The M0 the worker holds is the per-request cancel_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() → CancelProbe dtor → CancelMonitor::m_on_cancel → Lock cancel_lock{cancel_mutex}.
On a remote disconnect, capnp's RpcConnectionState::disconnect() runs first, directly from taskFailed, before the onDisconnect handler destroys the Connection. That path never touches cancel_mutex and never waits for in-flight workers. That's the unprotected window.
(TSAN's "created at" stack for M0 points at a different request's post; expected, since cancel_mutex is 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 race
Minimal 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 drives taskFailed → disconnect() concurrently with the worker's getResults().
Antithesis' coverage instrumentation (__sanitizer_cov_trace_pc_guard_internal → sleep_for) lands inside the window, which is why TSAN adds As if synchronized via sleep — that's what widened the race, not a false positive.
Possible fixes
- Route
getParams()/getResults() through loop.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
MessageBuilder before dispatch and write results back in a single loop.sync() after the body returns. One hop per call, and it removes the need for the cancel_mutex / request_canceled handshake entirely, since the worker would no longer alias anything the event loop can free.
- Make the
cancel_mutex handshake cover capnp's internal disconnect — would need a hook inside RpcConnectionState::disconnect(), i.e. patching capnp.
Found by ThreadSanitizer in a Bitcoin Core
bitcoin-nodebuild (libmultiprocesse8de5c7b68, capnproto 1.0.1) running under Antithesis (see here for how use it in Bitcoin Core).(Full antithesis log)
LLM analysis: