Audit report AR-73
Reported privately in an independent security review, 2026-09-28.
- Report date
- 2026-09-28
- Kind
- Bug
- Status on master
- Present
- Proposed issue state
- Open
- Linked bugs
- Bug 102: Process aborts when two thread proxies post to one thread at once
Summary
A thread's Waiter holds one pending call, and the gate that keeps a thread proxy from posting a second call before the first starts only covers that one proxy. One thread can have several thread proxies, so two can post at once, and the assertion after the post aborts the process. The report is right about the gap. Two details differ from the code: Bitcoin Core builds always keep assertions on, so the result is an abort in every build, and reaching it needs a client that overlaps calls across proxies, which the libmultiprocess C++ client does not do. Bug page: Process aborts when two thread proxies post to one thread at once.
The report
Two IPC connections can target the same thread through separate thread proxies (ProxyServer<Thread> objects). Each proxy has its own readiness gate, so the gates do not stop both from posting to the thread's shared Waiter, which accepts only one pending callback. The report says the second post is rejected, which asserts in Debug builds and leaves the call unresolved in Release builds.
The report rates it low. The Waiter behaviour is shown by a unit test that posts twice and drains once; the path through two connections was established from the source, and no Bitcoin Core call sequence causing it was reproduced. The scope is the libmultiprocess API, with no peer-to-peer or funds effect.
Proposed change
Queue pending functions in arrival order instead of keeping one, and drop the assertion that the post succeeded. The per-proxy gate stays as it is.
template <typename Fn>
- bool post(Fn&& fn)
+ void post(Fn&& fn)
{
const Lock lock(m_mutex);
- if (m_fn) return false;
- m_fn = std::forward<Fn>(fn);
+ m_fns.push_back(std::forward<Fn>(fn));
m_cv.notify_all();
- return true;
}
...
- std::optional<kj::Function<void()>> m_fn MP_GUARDED_BY(m_mutex);
+ std::deque<kj::Function<void()>> m_fns MP_GUARDED_BY(m_mutex);
The waiting loop drains the queue in order, ProxyServer<Thread>::post() no longer checks a return value, and the thread proxy destructor asserts the queue is empty. The new test "Waiter records a second post before draining" posts two functions to one Waiter and expects both to run. The report considered one gate shared by all proxies for a thread, which would serialize the posts but add coordination across connections.
Assessment
Accurate about the gap. On libmultiprocess master, Waiter::post() returns false when its slot is full, and ProxyServer<Thread>::post() chains each post on that proxy's own m_thread_ready promise and then asserts the post succeeded. The gate and the assertion came from mp#240 (merged 2026-02-20, in Bitcoin Core v31.0). A thread does have several proxies sharing one Waiter: one callback-thread proxy for each connection it calls over (created in type-context.h), plus the request-thread proxy of a server worker thread. Reading the code, this sharing also happens on a single connection when a server worker calls back to its client.
Two details differ:
- The assertion runs on the event loop thread and aborts the whole process. Bitcoin Core removes
NDEBUGfrom every build configuration, so this is an abort in release builds too. Only a standalone libmultiprocess build with assertions disabled takes the other path, and there, reading the code, the call is rejected with an error rather than left unresolved, and every later call through that proxy is rejected as well. - The libmultiprocess C++ client makes synchronous, nested calls, so a thread takes each posted function before another proxy can post. A collision needs a peer that sends a call for a thread through one proxy while another call for that thread is pending through a different proxy. That is the overlapping-call behaviour of Python and Rust clients that caused an earlier abort in the same single-slot
Waiter, now across proxies. No Bitcoin Core server code was found that calls out over a second connection while serving a request, so no path in Bitcoin Core today is known.
The report's test calls Waiter directly, so it shows the dropped post but not the assertion. A test that posts through two thread proxies would show the real failure. The queue is a small change. With it, a function posted through one proxy can run nested inside another call's wait on the same thread, which mp#240 already allows within one proxy.
Status
Present on master. Next step: a libmultiprocess issue or PR with the queue and a two-proxy test. No public issue or PR yet.
Reported privately in an independent security review on 2026-09-28.