Bug 5
Server reads freed request parameters when a client disconnects right after calling
- Status
- Fixed
- Symptom
- Crash; worst effect seen: crash
- Where seen
- A production node, Bitcoin Core functional tests, libmultiprocess tests (mptest)
- Trigger
- A client disconnects abruptly, for example because it is killed during a call
- First reported
- 2026-03-09 (first report)
- Fixed
- 2026-03-27
- Fix
- mp#249
- Shipped to Bitcoin Core
- bitcoin#34804, bitcoin#34952
- In released binaries
- No
- Defect ever on master
- Yes
Summary
An IPC server could crash with a use-after-free when a client disconnected right after sending an asynchronous request: the worker thread read the request's parameters after the disconnect had cancelled the request and Cap'n Proto had freed them. It was seen in Antithesis runs, in libmultiprocess sanitizer CI, and as a plain segfault with a Rust mining client against a Bitcoin Core master build. It was introduced by the request cancellation added in mp#240, which lets the server stop work on a call whose client has gone away. Fixed in mp#249 ("fixes for race conditions on disconnects") and shipped before any release contained the bug.
What happened
- Antithesis found an ASan
heap-use-after-freeincapnp::CallContext<BlockTemplate::GetBlockParams,...>::getParams(), called frommp::PassFieldintype-context.h, on abitcoindwhose IPC client had just disconnected. The log shows "IPC server: socket disconnected" and theMining/Initproxy servers being destroyed immediately before (bitcoin#34777, reported by @dergoegge, 2026-03-09). ryanofsky's diagnosis and patch followed. - mp#253, @Sjors's PR adding Bitcoin Core CI jobs to libmultiprocess, hit it once in its TSan
interface_ipc.pyjob, in the test "disconnect during BlockTemplate.waitNext", as a heap-use-after-free (virtual call vs free) ingetParams()attype-context.h:77(report, 2026-03-12). Later runs did not reproduce it. - The ASan/UBSan job of the same CI failed the same test on mp#256, a CI caching PR on a branch without the fix (report, @Sjors, 2026-03-25). The test's "canceled while executing" log line was missing, and node0 reported UBSan "member call on address ... which does not point to an object of type capnp::CallContextHook ... invalid vptr" at
capnp/capability.h:1129, theCallContext::getParams()line. The missing log line means the cancel arrived before the worker started, which is this bug's window, so this was most likely the same bug; the PR discussion called that attribution speculative. - @xyzconstant hit the same trace with the sv2-bitcoin-core Rust client against a Bitcoin Core master build from before the fix:
EXC_BAD_ACCESSingetParams()fromPassFieldattype-context.h:77after "request #200 interrupted (canceled)", about one run in two or three (report on mp#226, 2026-03-28). ryanofsky matched it to the fix, noting it was the first time the bug had been seen outside tests and Antithesis runs.
Root cause
The async-request lambda in PassField (include/mp/type-context.h) began with const auto& params = call_context.getParams(); before checking whether the request had been cancelled. Since 0174450ca2e9, the mp#240 commit that added cancellation, a disconnect cancels the request promise through Connection::m_canceler, and Cap'n Proto frees the request's params when the promise is cancelled. If the cancel lands after the worker thread is scheduled but before it runs, the worker reads freed memory. The cancellation check that follows (cancel_monitor.m_canceled, run under loop->sync) would have returned early, but by then the freed params had already been read.
Before mp#240 requests were never cancelled, so calling getParams() at any time was safe. The cancel has to land between the worker being scheduled and its first getParams() call, which takes a client that disconnects right after sending a request; a blocking C++ client could only do this by being killed in that window.
The code
PassField in include/mp/type-context.h, as of f5509a31fcca^. invoke runs on the worker thread; the sync() lambda and the code at the bottom run on the event-loop thread:
auto invoke = [self = kj::mv(self), call_context = kj::mv(server_context.call_context), &server, req, fn, args...](CancelMonitor& cancel_monitor) mutable {
// ...
const auto& params = call_context.getParams();
Context::Reader context_arg = Accessor::get(params);
// ...
server.m_context.loop->sync([&] {
// Detect request being canceled before it executes.
if (cancel_monitor.m_canceled) {
server_context.request_canceled = true;
return;
}
// ...
std::tie(request_thread, inserted) = SetThread(
GuardedRef{thread_context.waiter->m_mutex, request_threads}, server.m_context.connection,
[&] { return context_arg.getCallbackThread(); });
});
// ...
return server.m_context.connection->m_canceler.wrap(kj::mv(result));
The worker calls call_context.getParams() first and checks cancel_monitor.m_canceled only afterwards. If the client disconnects after the request is posted to the worker but before the worker runs:
- Event loop:
invokeis posted to the worker thread named in the request. - Event loop: the client disconnects,
m_cancelercancels the wrapped promise, and Cap'n Proto frees the call context and its params. - Worker:
call_context.getParams()makes a virtual call on the freedCallContextHook(the ASan, TSan and UBSan reports all point at this line). - Worker: the
m_canceledcheck would have returned early, but too late.
The fix drops the early read and reads the params inside the sync() lambda, after the cancellation check (f5509a31fcca, comment change trimmed):
@@ -76,8 +76,6 @@
if (loop.testing_hook_async_request_start) loop.testing_hook_async_request_start();
- const auto& params = call_context.getParams();
- Context::Reader context_arg = Accessor::get(params);
ServerContext server_context{server, call_context, req};
@@ -128,10 +126,13 @@
std::tie(request_thread, inserted) = SetThread(
GuardedRef{thread_context.waiter->m_mutex, request_threads}, server.m_context.connection,
- [&] { return context_arg.getCallbackThread(); });
+ [&] { return Accessor::get(call_context.getParams()).getCallbackThread(); });
});
Cancellation also runs on the event-loop thread, so once the sync() lambda has seen m_canceled false, the params cannot be freed before getParams() returns.
Fix
The fix commit f5509a31fcca removes the early getParams() and reads Accessor::get(call_context.getParams()).getCallbackThread() inside the sync block, after the not-cancelled check, on the event loop thread where cancellation cannot interleave (+5/-4). The follow-up refactor ff1d8ba172ad narrows the outer params scope for the same reason. The test "Calling async IPC method, with server disconnect racing the call" (4a60c39f24a5, @Sjors) disconnects from a hook at request start and crashes without the fix.
Timeline
- 2026-02-20: introduced by 0174450ca2e9, merged in mp#240; in Bitcoin Core from 2026-03-03 through the subtree update bitcoin#34422.
- 2026-03-09: bitcoin#34777, Antithesis report.
- 2026-03-12: TSan CI hit in mp#253.
- 2026-03-25: ASan/UBSan CI hit in mp#256 (most likely this bug).
- 2026-03-27: fix merged in mp#249.
- 2026-03-28: Rust client crash on a master build reported in mp#226.
- 2026-03-30: shipped to Bitcoin Core master in the subtree update bitcoin#34804; 2026-04-01: 31.x backport bitcoin#34952.
- Never in a release: v30.x predates mp#240, and v31 has the fix.
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 Serving before ready, freeing before done: Reading a request after it was cancelled
- Prevented by a different API shape, a review checklist item
- Earliest detector a disconnect test (did not exist then)
- Diagnosis easy
- Debugging aids other, a hook to force the timing
- Rust yes
Would Rust have prevented it? Yes. A capnproto-rust params reader borrows the call context, so it could not be handed to a worker thread or read after the call was cancelled and its context dropped; the worker would have to be given an owned copy of what it needs before the hand-off, which is what reading getCallbackThread() inside the loop-thread sync amounts to. More on Rust
Could it have been prevented?
Largely. mp#240 added a new lifetime rule, "once the request promise is cancelled its
params are freed", and guarded the uses it knew about with cancel_mutex, but the
worker lambda's first line already read call_context.getParams() before any check.
The fix moves the read inside the loop->sync() block after the m_canceled test; the
rule it implies is that a worker must not touch a cancellable request outside the
section that has proved it uncancelled. An API shape that enforces that (the worker
gets the params only from the guarded section, not by capturing call_context) would
have made the bad line unwritable. Failing that, a review checklist item for any change
that adds cancellation or early destruction, "list every access to the object and show
each one is ordered after the check", would have found this line: it is the first line
of the lambda the commit was editing.
Could it have been found sooner?
Yes. The test that ships with the fix (4a60c39f24a5) disconnects from a hook at request start and crashes every time without the fix; only the hook was missing, and mp#160 had already shown how to disconnect from inside a server call. Instead the bug was caught 17 days after merge by Antithesis under ASan (bitcoin#34777), once the subtree reached Bitcoin Core, and twice more by the sanitizer jobs of the new libmultiprocess CI. A plain (non-sanitizer) run needed the Rust client to hit it one run in three, since the freed memory usually still holds readable data.
What made it hard to debug?
It was not. The ASan trace pointed at type-context.h:77, the diagnosis and patch
followed the same morning (issuecomment-4024285314, four hours after the report), and
Sjors had a hook-driven test the next day. Two things were less easy: the UBSan
"invalid vptr" sighting on mp#256 was only attributed to this bug by the survey, and the
Rust-client crash arrived the day after the fix merged, so the report-to-attribution
loop depended on ryanofsky recognizing the trace. Cancellation-aware logging ("request #N
interrupted (canceled)") made that recognition possible.
Lesson
When you add cancellation or early destruction to an existing path, the new rule applies to every access that already exists, not only the ones you add; enumerate them, and give the test a hook that forces the cancel into the widest window rather than relying on a sanitizer to see it by chance.
Notes
- mp#249 fixed other disconnect races besides this one, in different code with different tests: a cancel arriving after the server method returns, and worker thread initialization in
makeThread(src/mp/proxy.cpp).
Classification
The survey's classification of this bug. The explorer charts these fields across all bugs; the method page explains them.
| Field | Value |
|---|---|
| Symptom | crash |
| Worst effect observed | crash |
| Seen in | production-node, functional-test, mptest |
| Scope | production |
| Trigger | Client disconnects abruptly (client-unclean-disconnect) |
| Phase | steady-state |
| Side | server |
| Component | type-context |
| Root cause kind | lifetime |
| Fix kind | local-fix |
| Detection | antithesis |
| Needs a peer that is not libmultiprocess C++ | yes |
| Needed a sanitizer to see | no |
| Defect ever on master | yes |
| Released | no |
| Confidence | high |