Bug 8
Node aborts when freeing a callback object from a client that disconnected
- 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
- 2025-09-29 (first report)
- Fixed
- 2026-06-02
- Fix
- mp#273
- Shipped to Bitcoin Core
- bitcoin#35661
- In released binaries
- Yes
- Defect ever on master
- Yes
Summary
When a client passes a callback object to the node, such as a handler for chain notifications, the node holds a ProxyClient for it and calls the callback's destroy method when it frees it. bitcoin-node aborted with std::terminate when a server-owned ProxyClient for a callback object was destroyed after the peer had disconnected: the proxy's cleanup made a remote destroy() call on the dead connection, which threw inside a noexcept destructor. It was seen with a Rust wallet client against the branch of bitcoin#29409, which adds an IPC wrapper for the node's Chain interface so a wallet can run as a separate process. Released binaries (v30.0 through v31.1) contain the code but cannot reach it, because no interface on master has a server-owned callback proxy with a destroy method. Fixed in mp#273.
What happened
- @TheCharlatan, running @darosior's
core_bdk_walletRust client against that branch, reported (2025-09-29) thatbitcoin-nodedied withterminate called after throwing an instance of 'ipc::Exception' what(): IPC client method call interrupted by disconnect.(SIGABRT) when the client went away. The gdb log showsChainNotifications.destroybeing sent after theHandlerserver was destroyed. ryanofsky opened mp#219 from it the same day and analysed the unclean Rust shutdown. - In April 2026 @xyzconstant reproduced the terminate with the same client and proposed a patch and test. A 2026-04-20 "still crashes on disconnect" trace from TheCharlatan shows only a gdb SIGPIPE stop in the event loop, which
bitcoin-nodeignores, so it is not counted as a second sighting of the terminate.
Root cause
The Rust client registered for chain notifications, then disconnected without first calling Handler.destroy and waiting for it. On the node, destroying the Handler server object destroys its ProxyClient<ChainNotifications>, whose cleanup lambda in ProxyClientBase (include/mp/proxy-io.h) calls Sub::destroy(*this) to make a ChainNotifications.destroy IPC call back to the client. The connection is already gone, so that call throws ipc::Exception inside a destructor that is implicitly noexcept, and std::terminate runs.
Making the destructor noexcept(false) alone did not help: xyzconstant found that the crash moved to "No event loop is running on this thread", and ryanofsky explained that the throw skipped the rest of the lambda, so m_client was later freed on the wrong thread.
Any peer that drops the connection while the node still owns a callback proxy for it can reach this, including a C++ wallet process that dies with handlers registered. A C++ client that shuts down normally tears its proxies down in order before closing, so the server never has to call back into a dead peer; only the Rust client, which drops the connection and lets objects be garbage collected, has hit it.
The unguarded call dates from the library's initial commit.
The code
ProxyClientBase in include/mp/proxy-io.h, as of 6de92e1c7324^. The constructor registers a cleanup function that the destructor runs, and the destructor is declared noexcept:
m_context.cleanup_fns.emplace_front([this, destroy_connection, disconnect_cb]{
{
// ...
Sub::destroy(*this);
// FIXME: Could just invoke removed addCleanup fn here instead of duplicating code
m_context.loop->sync([&]() {
// ...
if (m_context.connection) m_context.connection->removeSyncCleanup(disconnect_cb);
// Release client capability by move-assigning to temporary.
{
typename Interface::Client(std::move(m_client));
}
// ...
});
}
});
// ...
template <typename Interface, typename Impl>
ProxyClientBase<Interface, Impl>::~ProxyClientBase() noexcept
{
CleanupRun(m_context.cleanup_fns);
}
For interfaces with a destroy method, such as ChainNotifications, Sub::destroy makes a remote destroy call. When the node destroys its ProxyClient<ChainNotifications> after the Rust client has disconnected, that call throws ipc::Exception. The exception leaves a noexcept destructor and std::terminate ends the node. Removing noexcept would not be enough: the throw also skips the sync() block, so m_client is left to be released later on a thread with no event loop.
The fix catches the exception and logs a warning, so the rest of the cleanup runs (6de92e1c7324):
@@ -538,7 +538,12 @@ ProxyClientBase<Interface, Impl>::ProxyClientBase(typename Interface::Client cli
- Sub::destroy(*this);
+ // Exceptions are caught and logged rather than propagated because
+ // ~ProxyClientBase is noexcept and the peer may be gone by the time
+ // this runs.
+ if (kj::runCatchingExceptions([&]{ Sub::destroy(*this); }) != nullptr) {
+ MP_LOG(*m_context.loop, Log::Warning) << "Remote destroy call failed during cleanup. Continuing.";
+ }
No exception can now leave the destructor, and the sync() block still removes the disconnect callback and releases m_client on the event-loop thread.
Fix
6de92e1c7324 (xyzconstant) wraps Sub::destroy in kj::runCatchingExceptions and logs a warning, "Remote destroy call failed during cleanup. Continuing." (+6/-1). Catching there lets the rest of the cleanup lambda (removeSyncCleanup and releasing m_client inside loop->sync) run, so it covers the wrong-thread failure too. The test "Destroying ProxyClient<> with destroy method after peer disconnect" (90be8354d47f) saves a callback on the server and disconnects.
In review ryanofsky noted that suppressing all destroy-time errors is not ideal but better than terminating, and propagating them would need throwing destructors across every generated interface. @enirox001 tried that and found it "broadens the change quite a bit".
Timeline
- 2019-06-25: unguarded
destroy()call in the proxy cleanup since 06e1045baba0, the initial commit. - 2025-09-29: reported by @TheCharlatan on bitcoin#29409; mp#219 opened.
- 2026-04: reproduced by @xyzconstant.
- 2026-06-02: fix merged in mp#273.
- 2026-07-07: shipped to Bitcoin Core master by the subtree update bitcoin#35661.
- Releases: v30.0 through v31.1 contain the defect, but no interface they ship can trigger it.
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 Error paths that skip cleanup or completion: Assuming a remote call cannot fail
- Prevented by a design rule, a review checklist item
- Earliest detector a disconnect test (did not exist then)
- Diagnosis moderate
- Fix design moderate
- Debugging aids logging, symbolized stack traces, a reproduction script
- Rust partly
Would Rust have prevented it? Partly. The remote destroy would return a Result the compiler makes the Drop impl acknowledge, but Drop cannot propagate it and a panic there during unwinding aborts just as terminate does, so the choice to log and continue is still the author's. More on Rust
Could it have been prevented?
Yes, by a rule the project has since stated in more than one place: a call across a
process boundary can fail at any moment, and code that must complete regardless
(destructors, shutdown, cleanup) catches the failure and carries on. The
ProxyClientBase cleanup lambda has called Sub::destroy(*this), a round trip to the
peer, from a noexcept destructor since the 2019 initial commit
(06e1045baba0),
on the assumption that the peer is there to answer. It is the same assumption
bitcoin#32345 removed from
Shutdown()'s client->stop() a year earlier. A review item "does anything in a
destructor or cleanup path make a remote call, and what happens when it throws?" would
have found the line at any time. Whether to swallow or propagate is a real design
question, which is why the fix took a discussion: ryanofsky
preferred
propagating in principle, enirox001
found
that this means throwing destructors across every generated interface, and the merged
6de92e1c7324
logs a warning and continues, which is the only option a destructor really has.
Could it have been found sooner?
Yes, by the test the fix added: save a callback proxy on the server, disconnect the
client, destroy the proxy. That case is deterministic and needs no foreign client, but
until mp#273's regression
test nothing in either repository exercised it, because every C++ client tears its
handlers down and waits for Handler.destroy before closing, and no interface on
Bitcoin Core master gives the server a callback proxy with a destroy method. It took
the wallet-over-IPC branch (bitcoin#29409),
which has one, plus a Rust client that drops the connection and lets objects be
collected, for TheCharlatan to
hit it.
A foreign-client test would have found it too, but the in-process disconnect test is
cheaper and was possible from the day
mp#160 added the
disconnect harness.
What made it hard to debug?
The first report had a gdb backtrace through std::terminate and the IPC debug log,
and the log is what carried the diagnosis: "IPC server destroy ...Handler" followed
by "IPC client send ChainNotifications.destroy$Params" and then "interrupted by
disconnect" shows the order in three lines. Even so, ryanofsky's first reading put
the throw in ~ProxyServerBase, the issue then waited six months for a second
reproduction, and the obvious fix (noexcept(false)) failed in a way that needed its
own analysis: xyzconstant
found
the crash moved to "No event loop is running on this thread", and ryanofsky
worked out
that the throw had skipped the loop->sync() block below it, leaving m_client to be
released later on the wrong thread. Catching at the call, rather than letting it
escape, covers both failures at once. The fix's test exposed a further race in
clientDestroy the next day
(mp#286), so the path had
never been run in this order before.
Lesson
A destructor that talks to another process must assume the process is gone; catch the failure at the call so the rest of the cleanup still runs, and write the "peer disconnected, now destroy" test for every remotely owned object the moment such objects exist.
Notes
- The fix's regression test exposed a separate, pre-existing unsynchronized read of the connection pointer in
clientDestroy, fixed in mp#286 (bug page).
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 | unhandled-exception |
| Fix kind | local-fix |
| 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 |