Bug 33
Calling a method after disconnect binds a reference to a null connection
- Status
- Fixed
- Symptom
- Undefined behavior; worst effect seen: sanitizer report only
- Where seen
- Libmultiprocess tests (mptest)
- Trigger
- The process closes its own connection while the peer is still there
- First reported
- 2025-06-27 (first report)
- Fixed
- 2025-07-01
- Fix
- mp#186
- Shipped to Bitcoin Core
- bitcoin#32345
- In released binaries
- No
- Defect ever on master
- Yes
Summary
When a C++ IPC client called a method after its connection was closed, clientInvoke (the library function that sends a call and waits for the reply) bound a Connection& reference to a null pointer before checking for the disconnect. The reference was never used on that path, so the only effect was an UndefinedBehaviorSanitizer report in the mptest test "Call IPC method after client connection is closed". The bad ordering existed on libmultiprocess master for twelve days (2025-06-19 to 2025-07-01) and never reached Bitcoin Core's subtree or any release. Fixed in mp#186, a PR that fixed several mptest failures seen in Bitcoin Core CI.
What happened
@Sjors's pull request Sjors/bitcoin#90, a Bitcoin Core fork PR whose CI also builds and runs the libmultiprocess tests, failed its UBSan job twice on 2025-06-27/28 with:
[ TEST ] test.cpp:197: Call IPC method after client connection is closed
include/mp/proxy-types.h:612:40: runtime error: reference binding to null pointer of type 'Connection'
SUMMARY: UndefinedBehaviorSanitizer: null-pointer-use include/mp/proxy-types.h:612:40
The fix's commit message links both reports (first, second).
Root cause
A client proxy keeps a pointer to its Connection, and a disconnect callback sets that pointer to null when the connection goes away. The commit 56fff76f940b, part of mp#160 ("EventLoop locking cleanups + client disconnect exception"), moved clientInvoke's "called after disconnect" check from the calling thread into the lambda that loop->sync() runs on the event loop thread. It left this line above the sync call:
ClientInvokeContext invoke_context{*proxy_client.m_context.connection, g_thread_context};
After a disconnect, m_context.connection is null, so this binds a Connection& to *nullptr before the lambda runs its check. That is undefined behavior even though the lambda then returns early with "IPC client method called after disconnect." and never reads the reference. On the connected path the reference is only used inside the same sync() call, where the connection is alive. The fix's commit message says it "should not cause problems in practice because the reference is not used".
The code
Quoted from e09143d2ea2f^. clientInvoke (include/mp/proxy-types.h) builds its ClientInvokeContext on the calling thread, before the sync() lambda checks whether the connection still exists:
ClientInvokeContext invoke_context{*proxy_client.m_context.connection, g_thread_context};
// ...
proxy_client.m_context.loop->sync([&]() {
if (!proxy_client.m_context.connection) {
const std::unique_lock<std::mutex> lock(invoke_context.thread_context.waiter->m_mutex);
done = true;
disconnected = "IPC client method called after disconnect.";
invoke_context.thread_context.waiter->m_cv.notify_all();
return;
}
ClientInvokeContext (include/mp/proxy-io.h) holds the connection as a reference:
struct ClientInvokeContext : InvokeContext
{
ThreadContext& thread_context;
ClientInvokeContext(Connection& conn, ThreadContext& thread_context)
: InvokeContext{conn}, thread_context{thread_context}
After a disconnect, the disconnect callback has set m_context.connection to null, so a method call dereferences a null pointer to bind Connection& conn. The lambda then returns early without reading the reference, but binding it is already undefined behaviour, and UBSan reports "reference binding to null pointer of type 'Connection'" in the "Call IPC method after client connection is closed" test.
The fix (e09143d2ea2f) keeps the thread context separately and constructs the invoke context inside the lambda, after the check. The hunks that rename invoke_context.thread_context to thread_context and invoke_context to *invoke_context elsewhere are omitted:
@@ -609,42 +609,44 @@ void clientInvoke(ProxyClient& proxy_client, const GetRequest& get_request, Fiel
- ClientInvokeContext invoke_context{*proxy_client.m_context.connection, g_thread_context};
+ ThreadContext& thread_context{g_thread_context};
+ std::optional<ClientInvokeContext> invoke_context; // Must outlive waiter->wait() call below
@@
auto request = (proxy_client.m_client.*get_request)(nullptr);
using Request = CapRequestTraits<decltype(request)>;
using FieldList = typename ProxyClientMethodTraits<typename Request::Params>::Fields;
- IterateFields().handleChain(invoke_context, request, FieldList(), typename FieldObjs::BuildParams{&fields}...);
+ invoke_context.emplace(*proxy_client.m_context.connection, thread_context);
+ IterateFields().handleChain(*invoke_context, request, FieldList(), typename FieldObjs::BuildParams{&fields}...);
The reference is now bound only after the lambda has seen a non-null m_context.connection, and on the event-loop thread, where the disconnect callback that clears the pointer also runs, so it is never bound to null or to a connection being destroyed.
Fix
The fix commit e09143d2ea2f makes invoke_context a std::optional<ClientInvokeContext> declared in the outer scope and emplaces it inside the lambda only after the null check passes, taking ThreadContext& thread_context{g_thread_context} separately for the waiter and logging uses (+16/-14 in include/mp/proxy-types.h). Sjors asked why it was not declared inside the lambda; ryanofsky explained that it must outlive the waiter->wait() call because the request.send().then(...) continuation reads it, and added a comment saying so.
A side effect: the read of m_context.connection that built the reference used to happen on the calling thread with no synchronization against the disconnect callback that nulls it; it now happens on the event loop thread inside the same sync().
Timeline
- 2025-06-19: introduced by 56fff76f940b, merged in mp#160 together with the test that exercises it.
- 2025-06-27/28: UBSan failures on Sjors/bitcoin#90.
- 2025-07-01: fix merged in mp#186.
- 2025-08-18: mp#186 reached Bitcoin Core master in bitcoin#32345 ("ipc: Handle unclean shutdowns better", which included a subtree update). That update also brought in mp#160, so Bitcoin Core never had the defect.
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: A check moved away from the use it guards
- Prevented by a thread-affinity assertion, a review checklist item
- Earliest detector UBSan (did not exist then)
- Diagnosis easy
- Fix design easy
- Debugging aids sanitizer output, a clear assertion message
- Rust yes
Would Rust have prevented it? Yes. Rust has no null references: the connection would be an Option or a raw pointer that must be checked before a reference exists, so the reference could only be formed inside the branch that has seen Some (an unwrap() before the check would panic instead of being silent UB). More on Rust
Could it have been prevented?
Yes. The introducing commit
(56fff76f940b,
mp#160) moved the "called
after disconnect" check into the loop->sync() lambda but left the line that
dereferences m_context.connection above it, so the check now guarded nothing. The
underlying rule is thread affinity: m_context.connection is written by the disconnect
callback on the event-loop thread, so it should only be read there, and an
assert(loop->isLoopThread()) on that read would have failed the very test the same PR
added. Short of that, a review item "when a check moves, does every use it guards move
with it?" catches this diff in one reading; the fix
(e09143d2ea2f)
does exactly that by constructing the context inside the lambda after the check.
Could it have been found sooner?
Yes, and it nearly was: the test that exercises the path ("Call IPC method after client
connection is closed") landed in the same PR, so any UBSan run of mptest would have
reported it on the day of the merge. libmultiprocess had no UBSan job then; the first
UBSan run of the new test was Bitcoin Core's CI on a fork PR a week later, which reported
it twice. Enabling UBSan in the library's own sanitizer job was
deferred
during the fix because of unexplained interactions with TSan, so the library still relied
on a downstream CI to see this class of report.
What made it hard to debug?
Nothing. UBSan named the file and line and the message ("reference binding to null
pointer of type 'Connection'") says what happened. The fix took four days from report to
merge, and the only design question in review was
why
the std::optional lives in the outer scope (it must outlive waiter->wait() because the
send continuation reads it), which ryanofsky answered with a comment in the code.
Lesson
When a guard moves to a different thread or scope, move every operation it protects with it, and dereference a pointer another thread can clear only on the thread that clears it. A sanitizer run on the test that ships with a change is the cheapest check there is; make sure the project runs one rather than relying on a downstream CI to do it.
Notes
- libmultiprocess had no UBSan job at the time. ryanofsky deferred enabling UBSan in the new sanitizer job that mp#186 added, because of unexplained interactions with TSan.
Classification
The survey's classification of this bug. The explorer charts these fields across all bugs; the method page explains them.
| Field | Value |
|---|---|
| Symptom | ub |
| Worst effect observed | sanitizer-report-only |
| Seen in | mptest |
| Scope | production |
| Trigger | Process closes its own connection (local-disconnect) |
| Phase | steady-state |
| Side | client |
| Component | proxy-types |
| Root cause kind | ordering |
| Fix kind | local-fix |
| Detection | ubsan |
| Needs a peer that is not libmultiprocess C++ | no |
| Needed a sanitizer to see | yes |
| Defect ever on master | yes |
| Released | no |
| Confidence | high |