Bug 20

Server crashes when a client disconnects during a long-running call

Status
Fixed
Symptom
Crash; worst effect seen: crash
Where seen
Branch-build
Trigger
A client disconnects abruptly, for example because it is killed during a call
First reported
2024-10-08 (first report)
Fixed
2024-10-16
Fix
mp#118
Shipped to Bitcoin Core
bitcoin#31105
In released binaries
No; the code was in source releases only
Defect ever on master
Yes

Summary

If an IPC client disconnected while a server method it had called was still running, the server crashed when that method returned. While a call runs, the server's worker thread keeps an entry in its request_threads map that records which client thread to send callbacks to. The worker thread erased that entry after the call through an iterator saved before the call, but the disconnect had already removed the entry on the event loop thread. @tdb3 hit it every time by pressing Ctrl-C on a mining client blocked in a long wait and then mining a block. Source builds of v22 through v28 with multiprocess enabled have the bug, which became reachable on master once the mining interface had methods that wait a long time. Fixed in mp#118 by erasing the entry by key instead.

What happened

Reviewing bitcoin#31003 ("Add waitFeesChanged() to Mining interface"), @tdb3 ran the bitcoin-mine sample client from bitcoin#30437 ("ipc: add bitcoin-mine test program") against bitcoin-node, pressed Ctrl-C while the client was blocked in waitFeesChanged, then generated a block (2024-10-08). bitcoin-node segfaulted every time, on the worker thread, inside std::_Rb_tree_rebalance_for_erase called from request_threads.erase in the PassField Context overload; the review includes the gdb backtrace. ryanofsky reproduced it from the steps and opened the fix PR, mp#118 ("shutdown bugfix: Prevent segfault in server if connection is broken during long function call"), the same day.

Root cause

The PassField overload for Context arguments (then in include/mp/proxy-types.h, now type-context.h) runs the server method on a worker thread. Before the call it inserts the caller's callbackThread into the worker's request_threads map and keeps the returned iterator, and a KJ_DEFER erases by that iterator after the method returns.

When the client disconnected mid-call, the event-loop thread destroyed the Connection. Its sync cleanup callback erased that same map entry. That callback was registered by SetThread, which mp#106 added two months earlier so that map entries do not outlive their connection (its page). The fix's code comment also names request_threads.clear() in ~ProxyServer<Thread>, but that destructor asserts the thread is idle, so whether it can run during a call is unconfirmed; it is at most a possible second path. The worker's saved iterator then pointed at a freed tree node, and the deferred erase(iterator) corrupted the map and crashed.

The erase-by-iterator dates from the library's initial commit; mp#106 added the cleanup callback that made the entry disappear on a mid-call disconnect. The crash also needed a server method that blocks for a long time while the client goes away, which the mining interface's wait methods were the first to provide.

The code

Quoted from 9d11042772a2^. On the worker thread, the Context overload of PassField (include/mp/proxy-types.h) adds the caller's thread to request_threads, keeps the iterator, and erases by that iterator after the method returns:

auto [request_thread, inserted]{SetThread(
    request_threads, thread_context.waiter->m_mutex,
    server.m_context.connection,
    [&] { return context_arg.getCallbackThread(); })};
// ...
auto erase_thread{inserted ? request_thread : request_threads.end()};
KJ_DEFER(if (erase_thread != request_threads.end()) {
    std::unique_lock<std::mutex> lock(thread_context.waiter->m_mutex);
    request_threads.erase(erase_thread);
});
fn.invoke(server_context, args...);

SetThread (src/mp/proxy.cpp) also registers a callback that the Connection destructor runs on the event-loop thread, erasing the same entry:

thread->second.setCleanup([&threads, &mutex, thread] {
    // ...
    thread->second.m_cleanup.reset();
    // Remove connection pointer about to be destroyed from the map
    std::unique_lock<std::mutex> lock(mutex);
    threads.erase(thread);
});

When the client disconnects during a long call such as waitFeesChanged:

  1. Worker: SetThread inserts the entry, and fn.invoke blocks waiting for fees to change.
  2. Event loop: the client goes away, ~Connection runs the cleanup callback, and the entry is erased and its tree node freed.
  3. Worker: the method returns, and the KJ_DEFER calls erase(erase_thread) with the iterator to the freed node, corrupting the map; bitcoin-node segfaults in _Rb_tree_rebalance_for_erase.

The fix erases by key instead of by iterator (9d11042772a2, the new comments are trimmed):

@@ -152,10 +152,19 @@ auto PassField(Priority<1>, TypeList<>, ServerContext& server_context, const Fn&
-                    auto erase_thread{inserted ? request_thread : request_threads.end()};
-                    KJ_DEFER(if (erase_thread != request_threads.end()) {
+                    const bool erase_thread{inserted};
+                    KJ_DEFER(if (erase_thread) {
                         std::unique_lock<std::mutex> lock(thread_context.waiter->m_mutex);
-                        request_threads.erase(erase_thread);
+                        request_threads.erase(server.m_context.connection);
                     });

Erasing by key under the same mutex does nothing if the disconnect callback already removed the entry, so the worker never touches a freed node.

Fix

The fix commit 9d11042772a2 erases by key, request_threads.erase(server.m_context.connection), under the waiter mutex, which does nothing if the entry is already gone, with a comment naming both invalidation paths. It also adds a comment in SetThread explaining why the iterator its own cleanup callback captures stays valid: ~ProxyClient<Thread> unregisters the callback if the entry is removed first (+17/-3).

With this fix applied, the same reproduction exposed a second crash on the same path: a ProxyServer destroyed after its Connection. mp#118 worked around it with a second commit, and mp#160 ("refactor: EventLoop locking cleanups + client disconnect exception") fixed it. It has its own page.

Timeline

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 Work or cleanup on the wrong thread: Cleanup runs on a thread that does not own the state

  • Prevented by a design rule, a thread-affinity assertion
  • Earliest detector a disconnect test (did not exist then)
  • Diagnosis easy
  • Fix design easy
  • Debugging aids symbolized stack traces, a reproduction script
  • Rust yes

Would Rust have prevented it? Yes. Holding an iterator into request_threads across the blocking call while another thread erases from the map is a borrow that outlives its mutex guard, which the compiler refuses, forcing the erase-by-key-under-the-lock shape the fix adopted; and with capnproto-rust's single-threaded (Rc, !Send) capabilities the worker could not keep a map of Thread proxies at all, so both erasers would be the loop thread, at the cost of a different model for blocking method bodies. More on Rust

Could it have been prevented?

Yes, at the point where the second eraser was added. The worker thread saved a map iterator before a server call that can block for minutes and erased through it afterwards; that was safe while the worker was the only thread touching request_threads. mp#106 then registered a connection cleanup callback that erases the same entry from the event-loop thread, and neither the review of that PR nor the code asked what happened to iterators the worker was holding. Two rules would each have stopped it: never hold an iterator across a wait into a container another thread may mutate (erase by key under the lock, which is the fix, 9d11042772a2), or give per-thread state one mutating thread and assert it, which is the rule mp#201 adopted for this same map a year later. The first was knowable the day mp#106 landed.

Could it have been found sooner?

Yes, by any test that disconnects a client while a server method is blocked, run under ASan so the erase through the freed tree node is reported rather than corrupting the map. No such test existed in either repository until the library's first disconnect tests in mp#160 (2025-06); ipc_tests only ever called short methods on a connection it closed cleanly. The bug also needed a server call long enough for a person to press Ctrl-C during it, which the Mining interface's wait methods and the interactive bitcoin-mine client were the first to provide, two months after mp#106.

What made it hard to debug?

Nothing. tdb3's review gave repeatable steps and a gdb backtrace ending in _Rb_tree_rebalance_for_erase under request_threads.erase, ryanofsky reproduced it and opened the fix the same day. The eight days to merge went to what the fix uncovered: with the erase no longer crashing, the same reproduction reached a ProxyServer destroyed after its Connection (mp-160 record), which mp#118 had to work around in a second commit. A disconnect-mid-call test in the library would have exposed both in one CI run instead of one manual reproduction at a time.

Lesson

An iterator or pointer held across a blocking call is a bet that no other thread mutates the container in the meantime; when you add a cleanup path from another thread, look for every such bet, and prefer idempotent erase-by-key under the lock.

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
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 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