Skip to content

type-context: fix async disconnect race condition found by antithesis - #349

Open
ryanofsky wants to merge 1 commit into
bitcoin-core:masterfrom
ryanofsky:pr/tsandis
Open

type-context: fix async disconnect race condition found by antithesis#349
ryanofsky wants to merge 1 commit into
bitcoin-core:masterfrom
ryanofsky:pr/tsandis

Conversation

@ryanofsky

Copy link
Copy Markdown
Collaborator

Fix a race condition reported in #348 where if a disconnect happens during an IPC call that uses a worker thread (an IPC call taking an mp.Context parameter), it can trigger a read-write race detected by TSAN, and also theoretically cause a null pointer dereference (described in the commit message).

The race condition happens because when RpcCallContext::getResults() is called for the first time, it checks the connection state. So currently if there is a disconnect, when the worker thread calls getResults, this can read connection state at the same time capnproto writes as it processes the disconnect.

Fix this issue by calling getResults once from the event loop thread before executing the IPC call on the worker thread, so the results message pointer will be cached, and future calls to getResults from the worker thread won't access the connection state or have any race condition.

This is a one-line fix with many comments and a test.

…ndling

ThreadSanitizer reported a data race between a server thread executing an
async request and the event loop thread handling an abrupt remote disconnect
(bitcoin-core#348): the server
thread called call_context.getResults(), which reads Cap'n Proto connection
state, while the event loop thread overwrote that state.

In addition to the general undefined behavior, the race has one interleaving
with a concrete failure: a server thread can dereference a null pointer and
crash the process, meaning a client that disconnects mid-call can take down
the server. RpcConnectionState::disconnect() (capnp/rpc.c++) runs on the event
loop thread and tears down the connection in two steps, moving the live
connection out of the RpcConnectionState::connection field (nulling the stored
pointer) and then flipping the field to its disconnected state. A server
thread calling getResults() between the two steps passes the is<Connected>()
check but then dereferences the nulled pointer. The other interleavings are
harmless: reading the field before both writes builds results into an outgoing
message that is simply never sent, and reading it after both writes takes the
normal disconnected code path, which builds results into a message detached
from the connection. There is no use-after-free, since the objects involved
stay alive through reference counts and the existing cancellation handshake.

The underlying problem is that connection state may only be accessed on the
event loop thread, and nothing lets libmultiprocess order server thread
accesses against the disconnect teardown:

- The teardown happens with no warning. The Connection::onDisconnect promise
  used to clean up after disconnects only fires after capnp has finished
  tearing down the connection and shutting down the stream.

- The in-flight request is not canceled first. With capnp's allowCancellation
  feature off (the default), LocalClient::callInternal (capnp/capability.c++)
  detaches a fork of the call promise, so capnp's teardown does not destroy
  the promise chain that would trigger the CancelMonitor cancellation
  handshake in PassField. Enabling allowCancellation would not help either:
  disconnect() would then cancel in-flight requests as part of its teardown,
  but only after the connection field has already been overwritten, so the
  cancellation handshake still could not order server thread reads against
  those writes, only narrow the window.

So no mutex or flag in libmultiprocess can help; the only options are making
server threads stop reading connection state, or patching capnp.

Fortunately, only the first getResults() call on a request reads connection
state, to decide whether to allocate the results struct inside a real outgoing
message or in a message detached from the connection
(RpcCallContext::getResults in capnp/rpc.c++). The response it allocates is
cached, and later getResults() calls return it without reading connection
state.

So fix the race by initializing the results struct on the event loop thread,
in the existing loop.sync() call that runs before a request executes. The
getResults() calls that later run on the server thread just return the cached
response and never touch connection state. The cost is that if the method
throws, the preallocated results message is wasted (error returns are built
separately), the same tradeoff capnp itself makes with its internal "force
initialization of response" getResults calls.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@DrahtBot

DrahtBot commented Aug 19, 2026

Copy link
Copy Markdown

The following sections might be updated with supplementary metadata relevant to reviewers and maintainers.

Reviews

See the guideline and AI policy for information on the review process.
A summary of reviews will appear here.

Conflicts

Reviewers, this pull request conflicts with the following ones:

  • #336 (proxy-io: Reference-count Connection objects by ryanofsky)

If you consider this pull request important, please also help to review the conflicting pull requests. Ideally, start with the one that should be merged first.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants