Bug 30
Server crashes using a freed connection after a client disconnects mid-call
- Status
- Fixed
- Symptom
- Crash; worst effect seen: crash
- Where seen
- Branch-build, libmultiprocess tests (mptest)
- Trigger
- A client disconnects abruptly, for example because it is killed during a call
- First reported
- 2024-10-16 (first report)
- Fixed
- 2025-06-19
- Shipped to Bitcoin Core
- bitcoin#31105, bitcoin#32345
- In released binaries
- No; the code was in source releases only
- Defect ever on master
- Yes
Summary
When an IPC client disconnected while a server method it had called was still running, the server's Connection object was destroyed before the ProxyServer object handling the call. Code that later found the event loop through the ProxyServer's m_context.connection pointer read the freed Connection, and bitcoin-node crashed. It was seen three times over eight months: with a mining client killed during a long wait, with a Rust wallet client connecting and disconnecting in a loop, and with bitcoin-cli -ipcconnect run repeatedly against a node. A workaround in mp#118 covered only the first of three code paths, at the cost of a memory leak. The fix came in mp#160, shipped to Bitcoin Core in bitcoin#32345. Source builds with multiprocess enabled before v30.0 have the bug.
What happened
- mp#118 first fixed a crash on a stale map iterator in the same scenario (its page). With that fix applied, ryanofsky followed @tdb3's steps (Ctrl-C on the
bitcoin-mineclient while it is blocked inwaitFeesChanged, added by bitcoin#31003, then mine a block) and got a second segfault (2024-10-16): the worker thread finished the call and crashed inEventLoop::postwiththis=0x0, reached fromserver.m_context.connection->m_loop.sync(...)in thePassFieldContextoverload. He landed a workaround the same day. A trace tdb3 posted on 2024-10-08 still shows the erase-by-iterator code, so it came from a build without the iterator fix and shows the earlier bug. - @darosior ran a Rust client that connected, called
Init.makeChainandChain.initMessage, and disconnected in a loop against bitcoin#32345 ("ipc: Handle unclean shutdowns better", then open) plus bitcoin#29409 ("multiprocess: Add capnp wrapper for Chain interface"). ryanofsky filed it as mp#182 ("IPC crash with client disconnecting during IPC call", 2025-06-10). After many iterations the node aborted withmp::EventLoopRef::operator*() const: Assertion 'm_loop' failed, and under ASan reported a heap-use-after-free inEventLoopRef::operator->()right after "IPC server: socket disconnected". - @pinheadmz ran
bitcoin-cli -getinfofrom fourwatchloops against a pruned mainnet node built from the branch of bitcoin#32297 ("bitcoin-cli: Add -ipcconnect option"), which did not have the fix yet (report, 2025-07-15; filed as mp#191, "Socket disconnect followed by segfault on macos/arm64").bitcoin-nodesegfaulted inserverDestroy<ProxyServer<Rpc>>->EventLoop::logwith a garbagethis, reached from~ProxyServer<Rpc>inside a Cap'n Proto promise destructor. ryanofsky attributed it to this bug, and it did not reproduce on top of bitcoin#32345, which carried the fix.
Root cause
The code assumed that a ProxyServer is always destroyed before or inside the destructor of its Connection, so m_context.connection stays valid; the old Connection::~Connection comment (src/mp/proxy.cpp) said so. ~Connection resets the RPC system expecting Cap'n Proto to release every server object before the reset returns. That does not hold while a call is in flight. The call's promise, and for a method with a Context parameter the worker thread running it, keep the ProxyServer alive, so the Connection is freed first. Three paths then read the freed Connection to find the event loop:
- the post-call path in
PassField(server.m_context.connection->m_loop.sync(...)), the mp#118 crash; ~ProxyServerBase(include/mp/proxy-io.h), which calledm_context.connection->addAsyncCleanup(...)(mp#182);serverDestroy(include/mp/proxy-types.h), logging throughserver.m_context.connection->m_loop(mp#191).
Until late 2024 every IPC peer was a subprocess that Bitcoin Core spawned and stopped in order, so a connection never closed with a call running. Long-blocking mining calls and external clients that disconnect on their own schedule made the window reachable, and reconnect loops made it routine.
The code
Connection::~Connection (src/mp/proxy.cpp, quoted from 949573da8411^) expects resetting the RPC system to destroy every server object of the connection before the destructor goes on:
Connection::~Connection()
{
// Shut down RPC system first, since this will garbage collect Server
// objects that were not freed before the connection was closed, some of
// which may call addAsyncCleanup and add more cleanup callbacks which can
// run below.
m_rpc_system.reset();
A server object with a call still running is not destroyed there: the call's promise, and for a method taking a Context the worker thread running it, keep the ProxyServer alive until the call finishes, after the Connection has been freed. Two places then reach the event loop through the freed m_context.connection. The worker, after the method returns, in PassField (include/mp/proxy-types.h, quoted from 196e6fcdbc1f^):
fn.invoke(server_context, args...);
}
KJ_IF_MAYBE(exception, kj::runCatchingExceptions([&]() {
server.m_context.connection->m_loop.sync([&] {
auto fulfiller_dispose = kj::mv(fulfiller);
fulfiller_dispose->fulfill(kj::mv(call_context));
});
And ~ProxyServerBase (include/mp/proxy-io.h, quoted from 949573da8411^), when the server object is finally destroyed:
m_context.connection->addAsyncCleanup([impl=std::move(m_impl), fns=std::move(m_context.cleanup_fns)]() mutable {
impl.reset();
CleanupRun(fns);
});
- Worker: runs a long server method.
- Event loop: the client disconnects;
~Connectionresets the RPC system, which cannot destroy the busyProxyServer, and theConnectionis freed. - Worker: the method returns and
PassFieldcallssync()on the freed connection'sm_loop(the mp#118 crash, withthis == nullptrinsideEventLoop::sync). - Later:
~ProxyServerBasecallsaddAsyncCleanupon the freed connection, which locks a mutex reached through freed memory (the mp#182 ASan report andm_loopassert).
The first change, 196e6fcdbc1f in mp#118, was a workaround: when the worker found its request_threads entry already gone it returned without fulfilling the promise, which avoided the step 3 dereference by leaking the server object. The fix is spread over three commits of about 130 lines in total, so no diff is shown here. In mp#160, 315ff537fb65 gives ProxyContext its own loop pointer and changes PassField to call server.m_context.loop->sync(...), and 949573da8411 moves addAsyncCleanup to EventLoop and has ~ProxyServerBase call m_context.loop->addAsyncCleanup(...). mp#186 then reverted the workaround (c6f7fdf17350), whose leaked server objects were making a new unit test hang.
The event loop outlives every connection and every server object, so once neither path goes through m_context.connection, a server object destroyed after its Connection no longer touches freed memory.
Fix
- Workaround, 196e6fcdbc1f ("bugfix: prevent null pointer dereference in server if client disconnects during method call", mp#118, 2024-10-16): when the worker saw its thread entry gone after the call, it returned without completing the request, so the
ProxyServerwas never destroyed. That avoided thePassFieldcrash by leaking the server object and its implementation, and left the other two paths. - 315ff537fb65 ("refactor: Add ProxyContext EventLoop* member"), in mp#160 ("refactor: EventLoop locking cleanups + client disconnect exception"), adds an event loop pointer to
ProxyContextand switchesPassField,serverDestroy,serverInvokeand the client code tom_context.loop, removing theconnection->m_loophops. - 949573da8411 ("Prevent IPC server crash if disconnected during IPC call", mp#160) moves
addAsyncCleanupfromConnectiontoEventLoop, so~ProxyServerBasequeues destruction of its implementation onm_context.loopwithout touching theConnection, and documents that the destructor may run after theConnectionis gone. 84cf56a0b5f4 adds the "disconnecting and blocking" test, which crashes reliably under AddressSanitizer without the fix. - c6f7fdf17350, in mp#186 ("Fix mptest failures in bitcoin CI"), reverts the mp#118 workaround. Its leaked servers made the new test hang intermittently in @Sjors's CI (report).
Timeline
- 2019-06-25: both
~ProxyServerBase'sm_connection->addAsyncCleanup(...)and the post-callm_connection->m_loop.sync(...)present since the library's initial commit 06e1045baba0. - 2024-10-16: first crash reproduced; workaround merged with mp#118 the same day, shipped in bitcoin#31105 ("Update libmultiprocess library") on 2024-10-21.
- 2025-06-10: mp#182 from @darosior's testing.
- 2025-06-19: fix merged in mp#160.
- 2025-07-01: workaround reverted in mp#186.
- 2025-07-15: @pinheadmz's crash on a branch built before the fix (mp#191).
- 2025-08-18: fix shipped to Bitcoin Core in bitcoin#32345, which included a subtree update with mp#160 and mp#186.
- Releases: v28.x and earlier have all three paths in source builds with multiprocess enabled; v29.x has the workaround but not the fix, so the
~ProxyServerBaseandserverDestroypaths remain; v30.0 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 An object outliving what it points to: A child object outlives its parent
- Prevented by a clear ownership model
- Earliest detector a disconnect test (did not exist then)
- Diagnosis moderate
- Debugging aids other, a reproduction script, a clear assertion message
- Rust yes
Would Rust have prevented it? Yes. A ProxyServer could not keep a plain reference to a Connection the RPC system frees first: it would need an Arc (keeping the connection alive past its teardown) or a Weak whose None it must handle; and with capnproto-rust's Rc-based server objects a worker thread could not keep the server alive past the loop thread's teardown in the first place, at the cost of a different model for blocking method bodies. More on Rust
Could it have been prevented?
Yes, by ownership. From the initial commit a ProxyServer reached its event loop through
a raw back pointer to the Connection that created it, on the documented assumption
(the old ~Connection comment) that the connection would always be destroyed last. An
in-flight call keeps the server alive past the connection, so the assumption was false
from day one; it only held because no peer ever left. The fix gives each proxy its own
EventLoopRef (ProxyContext::loop) and moves addAsyncCleanup to the loop, which is
the object that actually outlives everything. The rule: a child must hold a reference to
whatever it needs at destruction, never a pointer to a parent whose lifetime it does not
control. That rule was knowable in 2019; the lifetime comment in ~Connection shows the
author saw the dependency and chose to document it rather than remove it.
Could it have been found sooner?
Yes. The "disconnecting and blocking" test written with the fix (84cf56a0b5f4) crashes
reliably under ASan without it, and the library had ASan available all along; what it
lacked until 2025-06 was any test in which a peer disconnects with a call running. For
five years every peer was a subprocess Bitcoin Core spawned and stopped in order. The
first long-blocking method (waitFeesChanged, bitcoin#31003) and an interactive client
one could Ctrl-C produced the crash within days of existing, 1,940 days after the code
was written; a reconnect loop from a Rust client then made it routine.
What made it hard to debug?
Recognizing the assumption was quick: the same-day mp#118 workaround comment names
"ProxyServer objects destroyed before Connection objects". What was slow was accepting
the cost of fixing it. The workaround skipped the crashing line by leaking the server
(and calling the bug a null-pointer dereference, when the null came from reading freed
memory), which left two other dereference sites and later caused the mp#186 test hangs.
The proper fix waited eight months for darosior's ASan report (mp#182), which named the
freed object unambiguously where the first trace had only shown this=0x0. A third
sighting (mp#191) still needed a debugger and hand attribution because the branch
predated the fix. A sanitizer run of the first repro, or an assert in ~ProxyServerBase
that the connection was still alive, would have turned the mystery null into a clear
lifetime violation immediately.
Lesson
When you write a comment saying "X is always destroyed before Y", treat it as a bug report: either make Y own what it needs, or add an assert that fires when the order breaks. A workaround that skips the crashing line leaves the other lines.
Notes
- The workaround commit 196e6fcdbc1f calls the crash a "null pointer dereference". The
connectionpointer itself was not null: it pointed to a freedConnection, and the nullEventLoopvalue most likely came from reading that freed memory (the loop reference sits at the start of the object). It is a use-after-free, which is how ASan later reported the same bug in mp#182. - 315ff537fb65 is titled and described as a mechanical refactor, but it changes behavior in exactly this case, and the revert commit c6f7fdf17350 names it as what made the mp#118 workaround unnecessary.
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 | branch-build, mptest |
| Scope | production |
| Trigger | Client disconnects abruptly (client-unclean-disconnect) |
| Phase | steady-state |
| Side | server |
| Component | proxy-io |
| Root cause kind | lifetime |
| Fix kind | refactor |
| Detection | manual-test |
| Needs a peer that is not libmultiprocess C++ | no |
| Needed a sanitizer to see | no |
| Defect ever on master | yes |
| Released | source |
| Confidence | high |