Bug 18

Client crashes after reconnecting because it reuses a remote thread from the old connection

Status
Fixed
Symptom
Crash; worst effect seen: crash
Where seen
Bitcoin Core unit tests
Trigger
A peer disconnects cleanly
First reported
2024-08-07 (first report)
Fixed
2024-08-09
Fix
mp#106
Shipped to Bitcoin Core
bitcoin#30509
In released binaries
No; the code was in source releases only
Defect ever on master
Yes

Summary

A client thread that made IPC calls over one connection, disconnected, and then connected again could segfault. Each client thread remembers which worker thread on the server runs its calls, per connection, in a map keyed by the raw Connection* pointer. libmultiprocess never removed the entry when the connection went away. If the new Connection was allocated at the old one's address, the next call went to a thread belonging to the dead connection. It was found by a new Bitcoin Core unit test that disconnects and reconnects, before Bitcoin Core accepted IPC connections from outside programs. Source builds of v22 through v28 with multiprocess enabled have the defect, but nothing in them reconnects this way outside that test. Fixed in mp#106.

What happened

@TheCharlatan suggested that bitcoin#30509, which added -ipcbind/-ipcconnect on unix sockets, test binding and connecting on several sockets in one process. The ipc_tests case added for that disconnects and immediately reconnects, and the next CI run failed (DrahtBot CI report, 2024-08-07). ryanofsky diagnosed it the next day from the failing Cirrus task: it did not reproduce natively, but did inside the CI container, most likely because that container was 32-bit and its heap reused the freed address.

Root cause

Each client thread's ThreadContext keeps two maps, request_threads and callback_threads, both std::map<Connection*, ProxyClient<Thread>>, so a thread that has already obtained a remote worker thread for a connection reuses it on later calls. Nothing removed an entry when its Connection was destroyed. When the allocator gave the next Connection the same address, the lookup in the Context overload of CustomBuildField (include/mp/proxy-types.h) found the stale entry and sent the call to a Thread capability of the previous connection, whose server thread no longer existed, and the client segfaulted.

This was safe as long as a Bitcoin Core process had one connection per peer for its whole life. bitcoin#30509's test was the first workload where one thread used two connections in sequence that could share an address.

The code

Quoted from 8ba0d03b4439^. Each thread's ThreadContext (include/mp/proxy-io.h) remembers, per connection, the remote thread it makes calls on, keyed by the raw Connection pointer:

std::map<Connection*, ProxyClient<Thread>> callback_threads;
// ...
std::map<Connection*, ProxyClient<Thread>> request_threads;

Before every call, the Context overload of CustomBuildField (include/mp/proxy-types.h) looks the connection up and reuses whatever entry it finds:

auto request_thread = request_threads.find(&connection);
if (request_thread == request_threads.end()) {
    // ...
    auto request = connection.m_thread_map.makeThreadRequest();
    request.setName(thread_context.thread_name);
    request_thread =
        request_threads
            .emplace(std::piecewise_construct, std::forward_as_tuple(&connection),
                std::forward_as_tuple(request.send().getResult(), &connection, /* destroy_connection= */ false))
            .first; // Nonblocking due to capnp request pipelining.
}

auto context = output.init();
context.setThread(request_thread->second.m_client);
context.setCallbackThread(callback_thread->second.m_client);

Nothing removes an entry when its Connection is destroyed. In a test that connects, disconnects and reconnects in a loop:

  1. Client thread: makes a call on connection A; request_threads[&A] now holds a Thread capability for a worker in A's server.
  2. Connection A is destroyed, and the server's worker thread for it goes away; the map entry stays.
  3. Connection B is allocated at the same address as A.
  4. Client thread: makes a call on B; find(&connection) returns A's stale entry, and the call is built with a Thread capability from the destroyed connection, which segfaults.

The fix (8ba0d03b4439, mp#106, +93/-42, so no diff here) moves the find-or-insert into a new SetThread() in src/mp/proxy.cpp, which registers a Connection::addSyncCleanup callback that erases the entry when the connection is destroyed, and gives ProxyClient<Thread> a destructor that unregisters the callback if the entry goes first.

An entry can no longer outlive its connection, so a new connection at a recycled address starts with no entry and gets a fresh makeThread call.

Fix

The fix commit 8ba0d03b4439, in mp#106 ("Bugfix: Clean up ThreadContext pointers when Connection is destroyed"), adds SetThread() (src/mp/proxy.cpp), which looks up or creates the map entry under the thread's Waiter::m_mutex. It registers a Connection::addSyncCleanup callback, stored in a new ProxyClient<Thread>::m_cleanup, that erases the entry if the connection is destroyed first. A new ~ProxyClient<Thread> unregisters the callback if the thread entry goes first. The server-side PassField Context overload uses SetThread too and erases its entry under the lock. No new libmultiprocess test was added; the Bitcoin Core test that found it covers it.

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 An object outliving what it points to: A map keyed by an address that gets reused

  • Prevented by a clear ownership model, a design rule
  • Earliest detector a unit test (did not exist then)
  • Diagnosis moderate
  • Fix design hard
  • Debugging aids a reproduction script, CI artifacts
  • Rust partly

Would Rust have prevented it? Partly. Storing &Connection in a thread-local map would not compile because the map outlives the borrow, which pushes the design toward Weak or a unique id, but a map keyed by the pointer cast to usize is safe Rust with the same address-reuse bug. More on Rust

Could it have been prevented?

Yes. request_threads and callback_threads cached a per-connection resource keyed by the raw Connection* with nothing tying the entry's lifetime to the connection's, so the cache was correct only while no address was ever reused. The rule is old and general: a map keyed by an object's address must have its entry removed when that object is destroyed, or be keyed by something the allocator cannot recycle. The fix (8ba0d03b4439) applies the first half, registering a Connection::addSyncCleanup callback that erases the entry. In 2019 the assumption that a process has one connection per peer for its whole life was true of every caller, and ryanofsky's diagnosis names it as an assumption rather than an oversight; it became false when bitcoin#30509 let a process open connections on demand.

Could it have been found sooner?

Only by a test that disconnects and reconnects from the same thread, which is the test that found it, on its first CI run. No such test could have existed before there was a way to open a second connection. Sanitizers would not have helped: the bug needs the freed address to be handed out again, and ASan's quarantine delays reuse, which is also why the test passed natively on 64-bit and failed only in the 32-bit CI container. A debug allocator that reuses addresses eagerly, or keying the map by a unique connection id, would have made the failure deterministic on every platform.

What made it hard to debug?

The crash was allocator-dependent: DrahtBot flagged the Cirrus task an hour after the push, but it did not reproduce locally until ryanofsky ran the CI scripts inside the container, after which the diagnosis took a day. The harder part was the fix. Erasing the entry when the connection dies means a second thread (the event loop's) now mutates a map owned by the calling thread, and the callback that does it had to be unregistered if the thread's entry went first; that new path is what mp#118 found erasing through an invalidated iterator two months later and what mp#201 reworked a year after that. A reproduction that ran on every developer's machine would have kept the follow-on cases in the same round of review.

Lesson

Never key a cache by a pointer unless the entry is evicted in the pointee's destructor; and when adding that eviction, notice that it introduces a second writer to a structure that until then had one owner.

Notes

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 unit-test
Scope production
Trigger Clean disconnect (clean-disconnect)
Phase steady-state
Side client
Component thread-map
Root cause kind lifetime
Fix kind new-code-path
Detection new-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