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 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.