Bug 3
Node crashes when a disconnected client's call returns a new object
- Status
- Fixed
- Symptom
- Crash; worst effect seen: crash
- Where seen
- A production node, Bitcoin Core functional tests
- Trigger
- A client disconnects abruptly, for example because it is killed during a call
- First reported
- 2025-10-06 (first report)
- Fixed
- 2026-02-20
- Fix
- mp#240
- Shipped to Bitcoin Core
- bitcoin#34422
- In released binaries
- Yes (v30.0-v30.3)
- Defect ever on master
- Yes
Summary
bitcoin-node crashed when an IPC client disconnected while one of its calls was still running on the server and that call then returned an interface pointer, such as a BlockTemplate from waitNext. Nothing cancelled a server request when its connection went away. So when the call finished, the worker thread built a ProxyServer for the returned object from the already-deleted Connection, aborting on Assertion 'm_loop' failed or segfaulting. Rust Stratum v2 clients hit it repeatedly against v30.0rc3 and v30.2. Fixed in mp#240 ("Avoid errors from asynchronous (non-c++) clients"), which adds request cancellation, and shipped in Bitcoin Core v31; v30.0 through v30.3 have the bug.
What happened
- @plebhash's Rust sv2 logger against v30.0rc3 hit a
proxy.h:59assertion (EventLoopRef::operator*,assert(m_loop)) mixed in with a different crash (bitcoin#33554, 2025-10-06). He isolated the steps the next day: Ctrl-C the client duringwaitNext, then wait for fees to change. ryanofsky filed mp#226 ("IPC server abort when returning interface as connection broken", 2025-10-08), namingMakeProxyServerand the deletedConnection. - plebhash hit it again with the sv2-apps pool against v30.2 on mainnet (bitcoin#34250, "crash during IPC cleanup after
interruptWait()is called", 2026-01-10). @ismaelsadeeq reproduced it reliably and ryanofsky reproduced it with a Python client. - The same assertion appeared when an SRI Job Declarator Client connected to v30.2 during IBD and disconnected (report on bitcoin#33994, 2026-02-18, attributed to this bug by @Sjors and ryanofsky). It appeared again in bitcoin#34664 (2026-02-24), whose log shows the
Miningserver destroyed and "socket disconnected", then the abort right after the nextCreateNewBlock(). At the time ryanofsky hoped bitcoin#34661, which fixed mining calls made before the node finished starting up, would cover that report. Its log matches this bug's sequence, so it was most likely this bug. - @johnnyasantoss reported a variant on v30.2 (bitcoin#35545, 2026-06-16): with two Rust clients on one node, one killed before its request had started executing, the node aborted with
Assertion failed: (m_loop), function operator->, file proxy.h, line 60inSetThread. ryanofsky matched it to the same fix.
Root cause
When a connection died, Connection::~Connection tore down the RPC system and the ProxyServer objects, but a worker thread already running a request kept executing the C++ method. When the method returned a std::unique_ptr<BlockTemplate>, MakeProxyServer in include/mp/type-interface.h built a ProxyServer<BlockTemplate> from InvokeContext::connection, a reference to the deleted Connection, and used its dangling EventLoopRef. In the bitcoin#35545 variant the request had not started: its queued callback in PassField (include/mp/type-context.h) had captured the connection, and SetThread dereferenced connection->m_loop. Both are the same defect: nothing cancels a request whose connection has been destroyed.
A C++ client does not easily trigger this, because its calls block and its proxies are destroyed before it disconnects. Rust and Python clients can drop a connection with a long waitNext outstanding, and the Stratum v2 pool did exactly that on shutdown.
The code
Quoted from 0174450ca2e9^. The worker thread runs the server method and then builds the results. ServerRet::invoke (include/mp/proxy-types.h):
auto&& result = Parent::invoke(server_context, TypeList<>(), std::forward<Args>(args)...);
auto&& results = server_context.call_context.getResults();
InvokeContext& invoke_context = server_context;
BuildField(TypeList<decltype(result)>(), invoke_context, Make<StructField, Accessor>(results),
std::forward<decltype(result)>(result));
invoke_context.connection is a Connection& bound when the request started executing. When the result is an interface pointer, such as the std::unique_ptr<BlockTemplate> from waitNext, MakeProxyServer (include/mp/type-interface.h) wraps it in a new server object for that connection, and the ProxyContext constructor (src/mp/proxy.cpp) follows the connection's event-loop reference:
return kj::heap<ProxyServer<Interface>>(std::move(impl), context.connection);
// ...
ProxyContext::ProxyContext(Connection* connection) : connection(connection), loop{*connection->m_loop} {}
Nothing tells the worker that the connection has gone away:
- Worker: runs
waitNext, which blocks until fees rise. - Event loop: the client disconnects without waiting for the reply, and
~Connectiondestroys the connection. The request is not cancelled. - Worker:
waitNextreturns a new template;MakeProxyServerpasses the freedConnectiontoProxyContext, which readsm_loopfrom it and crashes.
In the bitcoin#35545 variant the request had not started yet when its client disconnected. The worker's first step, SetThread(..., server.m_context.connection, ...) inside PassField (include/mp/type-context.h), then used the destroyed connection's m_loop and hit the Assertion failed: (m_loop) abort.
The fix, 0174450ca2e9 in mp#240, is 251 lines across seven files, so no diff is shown. It adds a Connection::m_canceler that wraps each request's promise and is cancelled from ~Connection. A probe attached in ProxyServer<Thread>::post() records the cancellation, and PassField turns it into a request_canceled flag, checked on the event-loop thread before the request runs; ServerCall::invoke checks it after the method returns and throws instead of building results, and PassField catches and logs that exception. A cancel_mutex keeps the event loop from freeing the params and results while the worker is using them.
Once ~Connection has run, every request of that connection sees request_canceled set, before it starts or before it builds results, so the worker never passes the destroyed Connection to SetThread or MakeProxyServer.
Fix
The fix commit 0174450ca2e9 adds a cancellation path (+251/-16, no tests):
Connection::m_cancelerwraps each pendingPassFieldresult promise and cancels it from~Connection.ProxyServer<Thread>::post()attaches a probe that setsrequest_canceledwhen the promise is cancelled, andPassFieldalso checks for a request cancelled before it starts executing.ServerCall::invokerechecksrequest_canceledafter the method returns and throws instead of building results;PassFieldcatches and logs it ("IPC server request #N interrupted (canceled)").- A
cancel_mutexkeeps the event loop from freeing the params and results structs while the worker uses them. It was added after TSan flagged the new code in CI for bitcoin#34422, the subtree update carrying mp#240.
The commit message cites bitcoin#34250 and describes the in-execution case only. ryanofsky explained that the "canceled before it executes" branch covers the bitcoin#35545 variant, which had looked theoretical. Tests came in Bitcoin Core: the "disconnect during BlockTemplate.waitNext" case in interface_ipc.py, first proposed in bitcoin#34284 ("Add tests for unclean disconnect and thread busy behavior") and folded into bitcoin#34422.
Timeline
- 2019-06-25: no cancellation of server requests on disconnect since 06e1045baba0, the initial commit.
- 2025-10-06: first report, bitcoin#33554 (v30.0rc3); 2025-10-08: mp#226.
- 2026-01-10: bitcoin#34250 (v30.2).
- 2026-02-18 and 2026-02-24: sightings in bitcoin#33994 and bitcoin#34664.
- 2026-02-20: fix merged in mp#240.
- 2026-03-03: shipped to Bitcoin Core master in the subtree update bitcoin#34422.
- 2026-06-16: pre-execution variant on v30.2, bitcoin#35545.
- Releases: v30.0 through v30.3 have the bug; v31.0 has the fix. Not backported to 30.x, because the mining interface is experimental (decision).
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 design rule, a clear ownership model
- Earliest detector a disconnect test (did not exist then)
- Diagnosis moderate
- Fix design hard
- Debugging aids a clear assertion message, a reproduction script, logging
- Rust yes
Would Rust have prevented it? Yes. The Connection reference the worker keeps across a long method could not outlive the connection: it would be an Arc, delaying teardown until waitNext returns (possibly the client's whole timeout), or a Weak whose None the worker must handle; in capnproto-rust a server method is a future the RPC layer drops on disconnect, so cancellation comes with the model, though blocking work moved to another thread still needs the hand-off the fix designs. More on Rust
Could it have been prevented?
Partly. The initial commit
(06e1045baba0)
handed each request to a worker thread with a bare Connection& and gave ~Connection
no way to reach the worker; the request's lifetime was nested inside the connection's
only by assumption. A rule the fix now embodies, "any work a connection posts to
another thread is cancelled or awaited by the connection's destructor, and the worker
rechecks before touching the connection again", would have made the omission visible
in 2019, since Cap'n Proto's promise cancellation was already the mechanism the fix
uses. It would not have made the fix small: the honest reading of
0174450ca2e9
(seven files, +251/-16, a cancel_mutex added only after TSan objected in
bitcoin#34422)
is that cancellation on a threaded server is a mechanism, not a check, and the three
regressions it produced within weeks (mp#245, mp#249) show the mechanism was not
obvious even with the rule in hand.
Could it have been found sooner?
Yes, by a test that kills a client while a server call that returns an interface
pointer is blocked, then lets the call return. No such test existed in either project
until the fix: the library's disconnect tests from
mp#160 (2025-06) covered
disconnect during a call but not a call returning a new object afterwards, and the
C++ client's blocking calls and orderly destroy meant nothing in-tree abandoned a
request. The first peer that did was a Rust Stratum v2 pool being Ctrl-C'd during
waitNext, in the v30.0 release candidates. The "disconnect during
BlockTemplate.waitNext" case in interface_ipc.py
(bitcoin#34284, folded into
bitcoin#34422) is that test; the library commit itself carries none.
What made it hard to debug?
Recognizing the site was quick once the steps were isolated: plebhash's
recipe
(Ctrl-C during waitNext, wait for a fee change) let ryanofsky name MakeProxyServer
and the deleted Connection in
mp#226 two days after
the first report. What was hard was everything around that: the first trace was mixed
with a different abort in the same issue, the later reports
(bitcoin#34250,
bitcoin#34664,
bitcoin#35545) each arrived as a
new crash and were matched to the same defect weeks or months later, and the fix took
four months to start because it needed a design rather than a patch. The assert(m_loop)
message pointed at a dead EventLoopRef but not at whose call was still running; the
"IPC server destroy" and, after the fix, "request #N interrupted (canceled)" log lines
are what let a reader pair a disconnect with the call it orphaned. A log of requests
still executing per connection at ~Connection would have made every later sighting
recognizable from its log alone.
Lesson
Work dispatched to another thread on behalf of a connection outlives that connection unless something cancels or joins it; build the cancel path when the dispatch is written, and have the worker recheck its owner is alive at every point it reaches back.
Notes
- The new cancellation path introduced bugs of its own, fixed in mp#245 (an unlocked write) and mp#249 (reading parameters after a cancel and a cancel callback running after the request finished) before any release had it.
- bitcoin#34284's "expect a crash" test was dropped because on the Alpine/musl CI job the pre-fix
waitNexthung instead of crashing.
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 | assert, crash |
| Seen in | production-node, functional-test |
| Scope | production |
| Trigger | Client disconnects abruptly (client-unclean-disconnect) |
| Phase | steady-state |
| Side | server |
| Component | type-context |
| Root cause kind | missing-cancellation |
| Fix kind | new-code-path |
| Detection | user-report |
| Needs a peer that is not libmultiprocess C++ | no |
| Needed a sanitizer to see | no |
| Defect ever on master | yes |
| Released | binary |
| Confidence | high |