Bug 102
Process aborts when two thread proxies post to one thread at once
- Status
- Open
- Symptom
- Assertion failure; worst effect seen: test failure only
- Where seen
- Libmultiprocess tests (mptest)
- Trigger
- Several calls running at the same time
- First reported
- Reported privately in an independent security review, 2026-09-28
- Fixed
- Not yet
- In released binaries
- Yes (v31.0 onward)
- Defect ever on master
- Yes
Summary
Each thread that runs IPC calls has a Waiter that holds one pending call. Since mp#240, each thread proxy (ProxyServer<Thread>) waits for its previous call to start before posting the next, and then asserts that the post succeeded. But one thread can have several thread proxies, one per connection it makes calls over, so two of them can post before the thread picks up the first, and the assertion aborts the process. It was reported privately in an independent security review. The libmultiprocess C++ client cannot trigger it, and no Bitcoin Core call sequence that does is known; a client that overlaps calls across proxies could. No fix is merged yet.
What happened
Nothing has been seen in a running process. An independent security review reported on 2026-09-28 that two connections can post work to the same thread at the same time, and that the second post is dropped. Its test posts twice to a Waiter directly and shows that only one function runs. The path through two connections was argued from the source and not reproduced.
Root cause
Waiter::post() in include/mp/proxy-io.h stores the function to run in a single slot, m_fn, and returns false if the slot is already full. ProxyServer<Thread>::post() in the same file chains every post on that proxy's m_thread_ready promise, which is fulfilled when the previous posted function starts running, and then runs assert(posted). The code comment states the assumption: "new functions are only posted when m_thread_ready is signaled, so this should never happen".
The gate belongs to one proxy, but the Waiter belongs to the thread. CustomBuildField in include/mp/type-context.h creates a callback-thread proxy for every connection a thread makes calls over, and a server worker thread created by makeThread also has its own request-thread proxy. Each of these proxies has its own gate. If two of them post before the thread moves the first function out of m_fn, the second post fails. The assertion runs on the event loop thread, so the whole process aborts. Bitcoin Core always builds with assertions enabled.
In a standalone libmultiprocess build with assertions disabled, reading the code, the dropped function destroys its kj promise fulfillers. That rejects the call with an error and also rejects that proxy's m_thread_ready, so every later call through the proxy fails. This was not run.
A C++ client's calls on one thread are synchronous and nested: the thread waits for each call, and the peer posts back to it only while it is waiting, so it takes each function before the next one can arrive. Reaching the assertion 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, the same overlapping-call behaviour that caused the earlier single-slot crash, now across proxies.
Fix
No fix merged yet. The fix proposed with the report replaces the single slot with a queue, so every post is kept and run in arrival order, and removes the assertion:
- std::optional<kj::Function<void()>> m_fn MP_GUARDED_BY(m_mutex);
+ std::deque<kj::Function<void()>> m_fns MP_GUARDED_BY(m_mutex);
post() then always succeeds, the waiting loop drains the queue, and the per-proxy gates stay as they are.
Next steps
- Write a libmultiprocess test that posts through two
ProxyServer<Thread>objects for one thread, rather than callingWaiterdirectly, and check that it aborts on master. - Open a libmultiprocess issue or PR with the queue and that test.
- Check whether any Bitcoin Core server code makes calls over a second connection while serving a request, which would make the trigger reachable with today's clients.
Timeline
- 2026-01-20: per-proxy gate and
assert(posted)added in ddb5f74196f6 ("Allow simultaneous calls on same Context.thread"), part of mp#240, merged 2026-02-20. - 2026-03-03: reached Bitcoin Core with the subtree update in bitcoin#34422.
- 2026-09-28: reported privately in an independent security review.
- Releases: v31.0 and later have the assertion.
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 An in-process interface trusting what now comes from another process: An assertion on what is now input from a peer
- Prevented by a design rule
- Earliest detector a unit test (did not exist then)
- Diagnosis moderate
- Fix design easy
- Debugging aids a clear assertion message
- Rust no
Would Rust have prevented it? No. The gate guards one proxy while the slot belongs to the thread; that is a protocol and ownership mismatch, not a memory or data-race error, and a Rust assert! on the same assumption would panic the same way. More on Rust
Could it have been prevented?
Yes, by putting the guard on the thing it protects. The Waiter slot belongs to a
thread, but the m_thread_ready gate added in
mp#240 belongs to one
ProxyServer<Thread>, and a thread can have several. The assertion after the post
restated the gate's guarantee as if it covered the thread. Either a queue in the
Waiter (what the proposed fix does) or a gate per thread would have made the case
impossible to write.
Could it have been found sooner?
By a test that posts through two thread proxies for one thread. mp#240 added tests for
overlapping calls through one proxy, the case it was fixing, and nothing exercised a
second proxy, which the C++ client never uses this way. An independent security review
found it by reading the gate and the Waiter together; its test shows the dropped post
at the Waiter level only.
What made it hard to debug?
Nothing has failed yet. If it did, the abort would name assert(posted) and the comment
next to it explains the assumption, so the cause would be quick to find. Builds with
assertions disabled would instead show a rejected call followed by failures of every
later call through that proxy, which would be much harder to trace back.
Lesson
When a guard serializes access to a shared resource, attach it to the resource, not to one of the paths that reach it; an assertion that restates the guard's promise will be wrong as soon as a second path appears.
Classification
The survey's classification of this bug. The explorer charts these fields across all bugs; the method page explains them.
| Field | Value |
|---|---|
| Symptom | assert |
| Worst effect observed | test-failure-only |
| Seen in | mptest |
| Scope | production |
| Trigger | Concurrent calls (concurrent-calls) |
| Phase | steady-state |
| Side | both |
| Component | proxy-io |
| Root cause kind | assumption-violated |
| Fix kind | none |
| Detection | review |
| Needs a peer that is not libmultiprocess C++ | yes |
| Needed a sanitizer to see | no |
| Defect ever on master | yes |
| Released | binary |
| Confidence | medium |