Bug 79
Node can crash when a client dies while a call's reply is being built
- Status
- Fixed
- Symptom
- Data race; worst effect seen: sanitizer report only
- Where seen
- Bitcoin Core functional tests, libmultiprocess tests (mptest)
- Trigger
- A client disconnects abruptly, for example because it is killed during a call
- First reported
- 2026-08-19 (first report)
- Fixed
- 2026-08-26
- Fix
- mp#349
- In released binaries
- Yes (v30.0-v31.1)
- Defect ever on master
- Yes
Summary
When an IPC client died in the middle of a call, the server's worker thread could read Cap'n Proto connection state in its first getResults() call while the event-loop thread was tearing that state down. TSan reports it as a data race, and one interleaving dereferences a null pointer, so a client that disconnects mid-call could take down bitcoin-node. It was found by Antithesis running a TSan build; no crash was observed. Released binaries v30.0 through v31.1 contain it. Fixed in mp#349; not yet in Bitcoin Core's subtree.
What happened
- @dergoegge reported mp#348 (2026-08-19): a TSan
bitcoin-node(capnproto 1.5.0 from depends) running under Antithesis, with the harness SIGKILLing the IPC client while the node executedMining.createNewBlock. TSan flagged the worker thread'sRpcCallContext::getResults()readingRpcConnectionState::connectionwhile theb-capnp-loopthread wrote it inRpcConnectionState::disconnect(). The issue body included an LLM-written analysis; its capnp version was corrected in the first comment. - ryanofsky first asked whether the library's existing cancellation of calls on disconnect should already cover this, and the same day explained why it runs too late, with a one-line fix.
Root cause
For methods taking an mp.Context parameter, PassField (include/mp/type-context.h) runs the method on a worker thread, and the generated ServerRet::invoke (include/mp/proxy-types.h) then calls call_context.getResults() on that thread. Cap'n Proto's RpcCallContext::getResults() (capnp/rpc.c++) reads connection state on its first call, to decide whether to allocate the results inside a real outgoing message or a detached one, and caches the response after that.
On an abrupt disconnect, RpcConnectionState::disconnect() runs on the event-loop thread and tears the connection down in two steps: it moves the live connection out of the connection field (nulling the stored pointer), then flips the field to its disconnected state. A worker calling getResults() between the two steps passes the is<Connected>() check and dereferences the null pointer. The other interleavings are harmless.
Nothing in libmultiprocess can order the worker's read against this teardown. The Connection::onDisconnect promise used for cleanup fires only after the teardown, and the request is not cancelled first: with capnp's allowCancellation off (the default), LocalClient::callInternal detaches a fork of the call promise, so the CancelMonitor handshake that tells the worker thread its call was cancelled, added by mp#240 (request cancellation) and mp#249 (disconnect race fixes), never runs on this path. Enabling it would only narrow the window. mp#249's cancel_mutex keeps the params and results alive but does not cover this field.
The worker-thread getResults() call dates from the library's initial commit.
The code
Quoted from 914dc839f279^. For a method with an mp.Context parameter, the method body runs on a worker thread, and the generated ServerRet::invoke (include/mp/proxy-types.h) asks for the results struct there once the method returns:
void invoke(ServerContext& server_context, TypeList<>, Args&&... args) const
{
auto&& result = Parent::invoke(server_context, TypeList<>(), std::forward<Args>(args)...);
auto&& results = server_context.call_context.getResults();
Before handing the request to the worker, PassField (include/mp/type-context.h) already runs a block on the event-loop thread, but it does not touch the results:
loop.sync([&] {
// Detect request being canceled before it executes.
if (cancel_monitor.m_canceled) {
// ...
std::tie(request_thread, inserted) = SetThread(
GuardedRef{thread_context.waiter->m_mutex, request_threads}, server.m_context.connection,
[&] { return Accessor::get(call_context.getParams()).getCallbackThread(); });
});
The first getResults() call on a request reads Cap'n Proto's connection state (in RpcCallContext::getResults, capnp/rpc.c++) to decide where to allocate the response; later calls return the cached response. On an abrupt client disconnect, RpcConnectionState::disconnect() rewrites that state on the event-loop thread in two steps, before libmultiprocess's disconnect handler or any cancellation runs:
- Worker: the method returns;
ServerRet::invokecallsgetResults()for the first time. - Event loop:
disconnect()moves the live connection out of the state field, leaving a null pointer, then marks the field disconnected. - Worker: reading between those two writes, it sees the state as still connected and dereferences the null pointer, crashing the node. TSan reports the race whichever way the reads and writes interleave.
The fix makes the first getResults() call inside the existing event-loop block (914dc839f279; comment trimmed; the new testing_hook_misc and the test are omitted):
@@ -131,6 +131,18 @@ auto PassField(Priority<1>, TypeList<>, ServerContext& server_context, const Fn&
[&] { return Accessor::get(call_context.getParams()).getCallbackThread(); });
+ call_context.getResults();
});
The connection state is now read only on the event-loop thread, the same thread that tears it down, and the worker's later getResults() calls return the cached response without reading it.
Fix
914dc839f279 (co-authored with Claude) adds call_context.getResults(); inside the existing loop.sync() block that runs on the event-loop thread before the request is handed to the worker, so capnp caches the response there and the worker's later calls never touch connection state. The cost is a wasted preallocated results message when the method throws. The commit also adds a generic testing_hook_misc to EventLoop and the test "Calling async IPC method with a remote disconnect while results are built", which reproduces the race under TSan before the fix and is clean after. @xyzconstant's ACK summarises the mechanism.
Timeline
- 2019-06-25: worker-thread
getResults()since 06e1045baba0, the initial commit. - 2026-08-19: mp#348 reported by @dergoegge (Antithesis); mp#349 opened the same day.
- 2026-08-26: fix merged in mp#349.
- Not yet shipped: no subtree update since bitcoin#35720 (2026-07-14). v30.0 through v31.1 contain the race.
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 a thread-affinity assertion, a design rule
- Earliest detector ThreadSanitizer (did not exist then)
- Diagnosis moderate
- Fix design moderate
- Debugging aids sanitizer output, thread names, a hook to force the timing
- Rust yes
Would Rust have prevented it? Yes. Capnproto-rust's call context is !Send, so the worker thread could not call getResults() on it; the results would have to be built on the loop thread, or on the worker and sent back, which is the fix's shape at the cost of a different model for blocking method bodies. More on Rust
Could it have been prevented?
Partly. Cap'n Proto's CallContext is a KJ object, and KJ objects are single-threaded
by contract; libmultiprocess runs method bodies on worker threads and calls
call_context.getResults() there, relying on the observation that the results message,
once allocated, is plain memory. The trap is that the first getResults() call is not a
plain accessor: it reads RpcConnectionState::connection to decide where to allocate,
and that field is rewritten by disconnect() on the loop thread with no notification.
A rule that the worker receives only message readers and builders obtained on the loop
thread, never the context itself, would have put the allocation in the loop.sync()
block from the start, which is what
914dc839f279
does with one line. A thread-affinity assert inside capnp's getResults() would have
failed on the first mptest run in 2019, but that check does not exist in KJ, and the
library's whole design depends on reading capnp messages off the loop thread, so the
rule was not obvious in
06e1045baba0.
Could it have been found sooner?
Yes, by TSan on a test that drops the client between a method body returning and its
first getResults(), which is the test the fix adds with a new testing_hook_misc to
hold the worker at that point. The TSan job existed from 2025-06 and the disconnect
tests from mp#160,
mp#240 and
mp#249 all disconnect
mid-call, but none held the worker in this window, and the window is small: the read
must land between two writes in capnp's teardown. Antithesis found it because its
coverage instrumentation sleeps inside the callbacks, widening exactly this kind of
gap. Even a targeted test is fragile under TSan, since the teardown's own later reads
evict the racing write from TSan's shadow memory unless the worker's read comes first.
What made it hard to debug?
The report (mp#348) came
with a TSan trace whose b-capnp-loop thread name placed the writer, and an LLM-written
analysis that had the mechanism right and the capnp version wrong. ryanofsky's first
reaction
was that the onDisconnect cancellation from mp#249 should already cover it; the
same-day answer
needed a read of capnp/rpc.c++ to see that onDisconnect fires only after the
connection is destroyed, that allowCancellation off means the request is never
cancelled first, and that nothing in the library can order against the teardown. The
fix design then hinged on a detail of capnp, that only the first getResults() reads
connection state and later calls return a cached response, so pre-calling it on the
loop thread is enough; the alternatives on the table were copying every parameter or
patching capnp. The commit body records all of this, which is the main reason the
record is easy to verify now.
Lesson
When you move work off the thread that owns a library object, treat every method of that object as thread-affine until you have read its implementation, and take from it on the owning thread everything the worker will need; a getter that allocates on first use is a write in disguise.
Notes
- mp#348 stayed open after the fix merged, because the fix commit references it without a closing keyword. It was closed on 2026-09-28 with an assessment of how reachable the crash is: of the three ways the worker's read can interleave with the two writes in capnp's
disconnect(), only the middle one faults, and the worker reads the results struct only after the wrapped method returns, so an ordinary disconnect and that read have no common cause. Bitcoin Core v32.0rc2 ships the pre-fix code and no backport is planned. - Reproducing this under TSan needed the racing read to come first: re-reads during teardown evict the earlier write from TSan's shadow memory.
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 | functional-test, mptest |
| Scope | production |
| Trigger | Client disconnects abruptly (client-unclean-disconnect) |
| Phase | steady-state |
| Side | server |
| Component | type-context |
| Root cause kind | assumption-violated |
| Fix kind | one-line-tweak |
| Detection | antithesis |
| Needs a peer that is not libmultiprocess C++ | no |
| Needed a sanitizer to see | yes |
| Defect ever on master | yes |
| Released | binary |
| Confidence | high |