Bug 98

Process aborts instead of reporting an error when the IPC event loop fails to start

Status
Open
Symptom
Crash; worst effect seen: crash
Where seen
Bitcoin Core unit tests
Trigger
Node startup
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

Bitcoin Core starts its IPC event loop on a background thread the first time a process listens for or opens an IPC connection. If creating the loop fails, for example because the process has run out of file descriptors, the error escapes that thread and C++ terminates the whole process with an abort. The caller expects an exception it can report: bitcoin-node would otherwise exit with "Unable to bind to IPC address". It was reported privately in an independent security review, with a test that injects the failure; it has not been seen in a running process, and in most cases the process would have stopped anyway, just with a clear message instead of an abort. 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 throws an exception just before the event loop is constructed and checks that the error reaches the code that asked for the connection. The report says that without a fix the exception left the background thread and the process aborted with SIGABRT. That run has not been repeated independently.

Root cause

CapnpProtocol::startLoop() in src/ipc/capnp/protocol.cpp creates the event loop lazily, on the first connect(), listen() or makeStream() call. It starts a thread that constructs an mp::EventLoop, signals the waiting caller through a std::promise, and then runs the loop. The caller waits for that signal.

The thread's code has no try/catch. The EventLoop constructor sets up Cap'n Proto's asynchronous I/O and creates a socket pair, and either step throws if a system call fails, for example when no file descriptors are left. An exception that escapes a thread's entry function makes C++ call std::terminate, so the process aborts while the caller is still waiting. The callers are written to handle an exception: bitcoin-node wraps its -ipcbind setup in a try/catch that reports "Unable to bind to IPC address", and the interfaces::Ipc methods document that they throw on errors.

Running out of memory is not a practical trigger: the node and GUI already install a handler that terminates on out-of-memory. The difference is for system errors such as descriptor exhaustion.

Fix

No fix merged yet. The fix proposed with the report catches any exception while the loop is being created on the background thread, passes it to the caller through the promise (set_exception), clears the partly built state, and has the caller join the thread and rethrow. The same protocol object can then try again later:

-        promise.get_future().wait();
+        try {
+            future.get();
+        } catch (...) {
+            m_loop_thread.join();
+            throw;
+        }

Exceptions after the loop has started are outside that change. The report considered constructing the event loop on the caller's thread instead, which would change which thread owns it.

Next steps

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 Error paths that skip cleanup or completion: An exception escaping a thread

  • Prevented by a design rule
  • Earliest detector startup test (did not exist then)
  • Diagnosis easy
  • Fix design easy
  • Debugging aids a clear assertion message
  • Rust partly

Would Rust have prevented it? Partly. A panic or error in a spawned Rust thread does not abort the process; the sending half of the channel is dropped and the waiting caller gets a receive error, so the abort becomes an error by default, but the caller still learns only that startup failed unless the code sends the error across, as the C++ fix does with set_exception. More on Rust

Could it have been prevented?

Yes, with a rule for every thread entry function: catch everything at the top and hand the error to whoever is waiting. CapnpProtocol::startLoop() in 10afdf0280fa used a std::promise only to say "ready", so there was no channel for "failed", and the thread body assumed the EventLoop constructor could not throw, although it makes system calls that report failure as exceptions. The fix shows the rule: wrap the setup in try/catch and pass the exception through promise.set_exception().

Could it have been found sooner?

Yes, by a startup test that makes loop construction fail and checks the caller gets an exception. No such test existed: IPC tests start the loop successfully, and real failures need descriptor exhaustion at the moment of the first IPC call. An independent security review found it by looking for exceptions that can cross a thread boundary and confirmed it with an injected failure.

What made it hard to debug?

Little. The C++ runtime prints "terminate called after throwing an instance of ..." with the exception text before aborting, which points at the cause. In a real deployment the confusing part would be an abort during startup where the operator expected an error message such as "Unable to bind to IPC address".

Lesson

Code that starts a thread and waits for it to say it is ready also needs a way for the thread to say it failed; otherwise any exception during setup ends the whole process instead of reaching the caller who could report it.

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 Node startup (node-startup)
Phase startup
Side eventloop
Component bitcoin-ipc
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