Audit report AR-14
Reported privately in an independent security review, 2026-08-07.
- Report date
- 2026-08-07
- Kind
- Bug
- Status on master
- Present
- Proposed issue state
- Open
Summary
If anything throws while an IPC client is building a request, the event loop logs and discards the exception, no request is sent, and the calling thread waits forever for a reply. The report is correct. The hang is left over from an earlier fix (mp#260), whose own code comment predicted it. Nothing in today's request-building code throws in practice, so this is a real but latent bug with no fix yet.
The report
EventLoop::loop() catches exceptions thrown by the function a caller runs on the event-loop thread (the report names the member m_post_fn), logs them, clears the function pointer, and wakes the waiting caller without passing the failure on. clientInvoke does its request setup inside that function: it creates the request, serializes every argument through the BuildField functions, including any application-defined serializer, and sends it. If one of those steps throws:
- no request is sent and no response handler is installed;
- the completion flag that the caller waits on is never set;
- the exception never reaches the calling thread;
- the caller waits forever for a result that cannot arrive.
The reproduction is a standalone C++ program with its own EventLoopModel, not a test against the library. Its loop() catches and logs exceptions from the posted function and returns, as the library does. A client thread posts a function that throws "serializer rejected request" before it would set done, then waits on done; after 250 ms the program prints client_wait_status=deadlocked.
Proposed change
Store an exception_ptr when the posted function throws, and rethrow it on the posting thread; clientInvoke must fail before entering its wait when setup fails. The patch sketch is an outline:
void EventLoop::post(kj::Function<void()> fn)
{
+ std::exception_ptr posted_exception;
+ // Event-loop execution wrapper stores any exception and posting thread
+ // rethrows after m_post_fn completes.
The report also suggests a test in which a custom serializer throws during clientInvoke setup.
Assessment
Present on libmultiprocess master. mp#347 renamed post() to sync() and m_post_fn to m_sync_fn but left the behaviour unchanged: the loop runs the function under kj::runCatchingExceptions and logs EventLoop: m_sync_fn threw: .... In clientInvoke (include/mp/proxy-types.h) only the response callback catches exceptions into an exception_ptr; the request-building half has no guard, and the caller then blocks in thread_context.waiter->wait(lock, [&done]() { return done; }).
Before mp#260 (merged 2026-04-14), the same throw left the caller stuck in post() instead: A posted EventLoop callback that throws leaves the posting thread blocked forever. mp#260 made the loop survive the exception, which moved this caller's hang into clientInvoke's wait. The comment mp#260 added says the caller "will return without any indication of failure, which will likely cause other bugs". The report's description of the remaining hang is more precise than the original issue, mp#259.
Nothing realistic throws there today. The library's primitive builders use static_asserts rather than throwing, and Bitcoin Core's custom builders (serializing into a DataStream, UniValue::write) do not throw in normal use. What is left is Cap'n Proto size limits, bad_alloc, the ThreadMap request made while building a Context argument, or a future custom builder that validates its input. No peer is involved: the throw happens in the local process.
A throw from the thread-creation step inside the same setup would also leave a broken thread-map entry behind; see AR-34.
Bug page: An IPC client thread waits forever in clientInvoke when building the request throws.
Reproduction (2026-09-26). A test against Bitcoin Core master bfdcd97 (libmultiprocess v13 subtree) makes a custom argument builder throw and calls the method from a second thread: the call was still blocked after 5 seconds, and the event loop logged the discarded exception. The hang is exactly as the report describes and happens on every run. See the bug page's reproduction section.
Status
Open on libmultiprocess and Bitcoin Core master. The fix suggested during mp#260 review and in this report is the same: capture the exception on the event-loop thread, either in clientInvoke's setup function or in sync() itself, and rethrow it on the calling thread. No PR yet.
Reported privately in an independent security review on 2026-08-07.