Bug 100
IPC process aborts if its cleanup thread cannot be started
- Status
- Open
- Symptom
- Crash; worst effect seen: crash
- Where seen
- Bitcoin Core unit tests
- Trigger
- A client disconnects abruptly, for example because it is killed during a call
- First reported
- Reported privately in an independent security review, 2026-09-27
- Fixed
- Not yet
- In released binaries
- Yes (v30.0 onward)
- Defect ever on master
- Yes
Summary
When a client releases a server object without calling destroy, for example by disconnecting with objects still alive, libmultiprocess hands the object's cleanup to a background thread so the event loop is not blocked. That thread is created the first time it is needed, from inside the ProxyServer destructor. If creating it fails, for example because the process has reached its thread limit, the destructor cannot report the error and C++ terminates the whole process. It was reported privately in an independent security review, with a test that injects a failure at that step; it has not been seen in a running process, and the trigger needs a thread limit to be hit at one particular moment. No fix is merged yet.
What happened
Nothing has been seen in a deployment. An independent security review reported the problem privately on 2026-09-27. Its unit test injects an out-of-memory error into the cleanup handoff while a client disconnects and reports that the process terminated. The report names a failure to create the cleanup thread as the other way to reach it. Those runs have not been repeated independently.
Root cause
When a ProxyServer object is destroyed and its client never called destroy(), the server still owns the application object behind it. That happens for interfaces without a destroy method, for clients that simply drop a capability (as Python and Rust clients do), and for every object still alive when a connection drops. The destructor (~ProxyServerBase in include/mp/proxy-io.h) passes the object to EventLoop::addAsyncCleanup() (src/mp/proxy.cpp), which queues it for the asynchronous cleanup thread and, if that thread does not exist yet, creates it with std::thread.
Creating a thread can fail: pthread_create returns an error when the process, its user or its service unit (for example a systemd TasksMax setting) is at its thread limit, or when the thread's stack cannot be mapped. std::thread then throws std::system_error. Destructors in C++ cannot throw by default, so the exception calls std::terminate and the process aborts. The code assumed the handoff could not fail.
The handoff also allocates memory, and the report's test injects its failure there. In Bitcoin Core that is not a separate risk: the node and GUI install an out-of-memory handler that terminates the process before any allocation error is thrown. The same goes for the report's other path, where building a call's result fails on a worker thread and the new server object is destroyed there; that trips an assertion that the cleanup thread is only started from the event-loop thread, but only an allocation failure can cause it. Thread creation is the failure that the out-of-memory handler does not cover.
The thread is created at most once per event loop, at the first such release; since mp#160 it then stays until the loop ends. A connected client can make the node create threads without limit (see AR-04), so a client could reach the limit on purpose; otherwise ordinary limits would have to be nearly exhausted at that moment.
Fix
No fix merged yet. The fix proposed with the report creates the cleanup thread when the EventLoop is constructed, where a failure is an ordinary exception that reaches the caller, so no destructor ever starts a thread. It also replaces the queued std::function with a concrete entry and adds a fallback slot that needs no allocation if adding to the queue fails. The first part alone removes the trigger described here; the rest is hardening for applications that do not terminate on out-of-memory. See AR-62 for the full change and review notes.
Next steps
- Open a libmultiprocess issue or PR that starts the cleanup thread in the
EventLoopconstructor, with a test that makes thread creation fail. - Decide separately whether the allocation-free fallback slot is worth its complexity; it adds a wait on the event-loop thread that needs review.
- After a fix is merged, update Bitcoin Core's libmultiprocess subtree.
Timeline
- 2019-06-25: present since the libmultiprocess initial commit 06e1045baba0, which started the cleanup thread from
addAsyncCleanup()called by the server destructor. - 2024-09-09: reachable in Bitcoin Core once
bitcoin-nodegained the-ipcbindoption in bitcoin#30509. - 2025-06-19: mp#160 merged; the cleanup thread now stays alive once started, so it is created at most once per event loop.
- 2026-09-27: reported privately in an independent security review, with a fault-injection test.
- Releases: v30.0 and later ship
bitcoin-nodewith-ipcbind.
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: Fallible work in destructor
- Prevented by a design rule
- Earliest detector code review (did not exist then)
- Diagnosis easy
- Fix design moderate
- Debugging aids a clear assertion message
- Rust partly
Would Rust have prevented it? Partly. Std::thread::spawn panics when the OS refuses a thread, and a panic in Drop during normal execution unwinds rather than aborts, but Rust still offers no way to report the error from Drop, so the same rule applies: create the thread up front with Builder::spawn, which returns a Result. More on Rust
Could it have been prevented?
Yes, with a rule that destructors only release resources and never acquire them. The
server destructor in the
initial commit
hands its object to a cleanup queue and starts the cleanup thread on first use, so
the first destructor to need it is the one that pays for creating a thread, at a point
where failure cannot be reported. Lazy creation saved one idle thread per event loop.
The proposed fix shows the rule: create the thread in the EventLoop constructor,
where an error is an ordinary exception.
Could it have been found sooner?
Only by review or fault injection. A test would have to make thread creation fail at
the first server object released without destroy(), which no ordinary test does, and
allocation failures in the same path are invisible in Bitcoin Core because its
processes already terminate on out-of-memory. An independent security review found it
while injecting failures along IPC error paths.
What made it hard to debug?
Little, if it ever happened: the runtime prints "terminate called after throwing an
instance of 'std::system_error'" with the pthread_create error before aborting. The
confusing part would be the timing, an abort when a client disconnects, long after the
thread limit was approached by something else.
Lesson
Anything that can fail, such as creating a thread or allocating a queue entry, belongs in a constructor or an explicit start step, not in a destructor that runs on cleanup paths where the error has nowhere to go.
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 | Client disconnects abruptly (client-unclean-disconnect) |
| Phase | steady-state |
| Side | server |
| Component | eventloop |
| Root cause kind | unhandled-exception |
| Fix kind | none |
| Detection | review |
| Needs a peer that is not libmultiprocess C++ | no |
| Needed a sanitizer to see | no |
| Defect ever on master | yes |
| Released | binary |
| Confidence | low |