Bug 47
Node aborts when a client sends overlapping calls to one server thread
- Status
- Fixed
- Symptom
- Assertion failure; worst effect seen: assertion failure
- Where seen
- A production node, Bitcoin Core functional tests
- Trigger
- Several calls running at the same time
- First reported
- 2025-09-05 (first report)
- Fixed
- 2025-10-02
- Fix
- mp#214
- Shipped to Bitcoin Core
- bitcoin#33518, bitcoin#33519
- In released binaries
- Yes (v30.0)
- Defect ever on master
- Yes
Summary
An IPC server aborted on Assertion '!m_fn' failed in Waiter::post when a client sent a second request for a server thread that already had a request waiting. The libmultiprocess C++ client never does this, but Python and Rust clients that reused one Context.thread for overlapping asynchronous calls could crash bitcoin-node. It was seen in Bitcoin Core's first Python IPC functional test and twice with a Rust Stratum v2 pool. Fixed in mp#214, which returns a "thread busy" error instead. v30.0 has the bug; the fix reached the 30.x branch afterwards.
What happened
- @sipa reported (2025-09-05) a crash in CI for bitcoin#33201, the PR adding the first
interface_ipc.pyfunctional test (using pycapnp).bitcoin-nodedied withipc/libmultiprocess/include/mp/proxy-io.h:279: void mp::Waiter::post(Fn &&) ...: Assertion `!m_fn' failed.duringawait template2.result.waitNext(ctx, waitoptions), and the test hung waiting for a reply. He reproduced it locally about once in 60 runs. The test had accidentally left an earlierwaitNextcall un-awaited, so two requests were in flight for the same server thread. ryanofsky opened mp#206 the same day, asking for the server to return an error instead of asserting. - @plebhash hit the same assertion (
proxy-io.h:289) with v30.0rc1 and rc3 and the Rust sv2 pool, which created a newBlockTemplateevery 10 s on the thread with an outstandingwaitNext(bitcoin#33554, 2025-10-06). ryanofsky identified it as this bug. A second trace in that report, after a client was killed, was a different bug. - plebhash hit it again from
getBlockrunning concurrently withsubmitSolutionon one thread, on a build predating the fix (bitcoin#33923, 2025-11-21); ryanofsky pointed to mp#214.
Root cause
Each server worker thread has a Waiter, a small queue it waits on for its next call. Waiter::post in include/mp/proxy-io.h kept the function to run on the worker in a single slot, std::optional<kj::Function<void()>> m_fn, and asserted !m_fn before overwriting it. That matched the C++ client, which blocks on each call, so a server Thread never gets a new request while one is waiting. (One request can be executing and one waiting, since the worker clears m_fn before running it; that is why the regression test needs three calls to trigger it, as ryanofsky explained.) A non-C++ client that reuses a Context.thread capability for overlapping asynchronous calls breaks the assumption, and the event loop thread, posting the extra request from PassField in include/mp/type-context.h, aborted the process.
The single-slot design and the assertion date from the initial commit. Only non-C++ clients can reach it, and bitcoin#33201 in September 2025 added the first one to Bitcoin Core's test suite.
The code
Quoted from eb069ab75d83^. Each server worker thread has a Waiter (include/mp/proxy-io.h) with a single slot for the next function to run. post asserts the slot is empty, and the worker empties it just before running the function:
template <typename Fn>
void post(Fn&& fn)
{
const Lock lock(m_mutex);
assert(!m_fn);
m_fn = std::forward<Fn>(fn);
m_cv.notify_all();
}
// ...
while (m_fn) {
auto fn = std::move(*m_fn);
m_fn.reset();
Unlock(lock, fn);
}
On the event-loop thread, PassField (include/mp/type-context.h) posts every request to the thread named in its Context.thread field, with no check that the thread is free:
const auto& thread = static_cast<ProxyServer<Thread>&>(*thread_server);
server.m_context.loop->log()
<< "IPC server post request #" << req << " {" << thread.m_thread_context.thread_name << "}";
thread.m_thread_context.waiter->post(std::move(invoke));
The C++ client blocks on each call, so it never sends a second request on the same thread while the first is still queued. A client in another language can, by reusing one Context.thread for overlapping asynchronous calls:
- Event loop: posts request 1; the worker takes it out of
m_fnand starts running it. - Event loop: posts request 2 into the now-empty slot, where it waits.
- Event loop: posts request 3;
m_fnis still set andassert(!m_fn)aborts the whole server process.
The fix (eb069ab75d83) turns the assertion into a return value, and PassField fails only that request:
@@ -281,12 +281,13 @@ struct Waiter
- void post(Fn&& fn)
+ bool post(Fn&& fn)
{
const Lock lock(m_mutex);
- assert(!m_fn);
+ if (m_fn) return false;
m_fn = std::forward<Fn>(fn);
m_cv.notify_all();
+ return true;
}
@@ -152,7 +152,12 @@ auto PassField(Priority<1>, TypeList<>, ServerContext& server_context, const Fn&
- thread.m_thread_context.waiter->post(std::move(invoke));
+ if (!thread.m_thread_context.waiter->post(std::move(invoke))) {
+ server.m_context.loop->log()
+ << "IPC server error request #" << req
+ << " {" << thread.m_thread_context.thread_name << "}" << ", thread busy";
+ throw std::runtime_error("thread busy");
+ }
The exception is thrown inside the promise continuation for that request, so Cap'n Proto returns it to the client as a remote error for request 3 and the server keeps running.
Fix
The fix commit eb069ab75d83 (@Eunovo) makes post return false when m_fn is already set; PassField then logs "IPC server error request #N {thread}, thread busy" and throws std::runtime_error("thread busy"), which the client receives as a remote exception (+9/-3). The test "Make simultaneous IPC callbacks with same request_thread and callback_thread" (1238170f68e8) sends three requests on one thread with the raw Cap'n Proto client and expects the error; it is based on Eunovo's Python reproducer.
mp#240 ("Avoid errors from asynchronous (non-c++) clients", 2026-02) later replaced the error with a per-thread request queue, after which mp#206 was closed.
Timeline
- 2019-06-25: assertion present since 06e1045baba0, the initial commit.
- 2025-09-05: reported by @sipa on bitcoin#33201; mp#206 filed.
- 2025-10-02: fix merged in mp#214.
- 2025-10-06: Rust client sighting on v30.0rc3, bitcoin#33554.
- 2025-10-10: shipped to Bitcoin Core master in the subtree update bitcoin#33518; 2025-10-16: to the 30.x branch in the subtree update bitcoin#33519, after v30.0, which has the bug.
- 2025-11-21: Rust client sighting on a pre-fix build, bitcoin#33923.
- Releases: v30.0 binaries have the bug; v22 through v29 have it in source only.
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, a review checklist item
- Earliest detector a test with a non-C++ client (did not exist then)
- Diagnosis easy
- Fix design easy
- Debugging aids a clear assertion message, logging
- Rust no
Would Rust have prevented it? No. A protocol assumption (one outstanding request per thread) violated by a foreign client is a contract bug; in Rust the same assert! would panic and the same fix, returning an error, is a design choice. More on Rust
Could it have been prevented?
Yes. Waiter::post asserted !m_fn before storing the next request for a server
thread, which encoded the C++ client's behaviour (one blocking call per thread at a time)
as an invariant of the server. From the day a non-C++ client could name a Context.thread
capability, whether a second request arrives for that thread is the peer's choice, and
an assert on the peer's choice is a remote abort of bitcoin-node. The rule is the same
as for
bitcoin#33222:
at a wire boundary, a precondition on what the peer sends is input validation, and the
fix (eb069ab75d83)
is exactly that, post returns false and the request fails with "thread busy". The
review item is mechanical: list every assert reachable from a request's contents. The
assertion dates from the initial commit, when the only client was the library's own;
it became input the day bitcoin#30510
put the mining interface behind -ipcbind.
Could it have been found sooner?
Yes, by any client that does not serialize its calls. The C++ client cannot send two
requests on one thread, so no C++ test could reach the assert in six years; the first
pycapnp test in Bitcoin Core (bitcoin#33201)
reached it within days, by accident, when an un-awaited waitNext left a second call in
flight. Eunovo's regression test drives the raw messages::FooInterface::Client with three
requests on one thread, which is the shape of test that would have found it at any
point: a raw Cap'n Proto client in the library's own suite. The Rust Stratum v2 client
hit the same abort in the field a month later on v30.0rc3
(bitcoin#33554).
What made it hard to debug?
Little. The CI failure was intermittent (about one run in sixty) and the Python side only hung, but the node's stderr carried the assert with file and line, and ryanofsky named the cause on IRC within minutes and opened mp#206 the same day. The "IPC server post request #N {thread}" log line placed the extra request on the thread. What took a month was the fix's shape: returning an error was quick, and the question of whether the server should instead queue requests per thread was settled later by mp#240, which is where the next regressions came from.
Lesson
An assertion that holds because of how your own client behaves is a crash waiting for the first client written by someone else; when an interface goes over the wire, turn every such assert into a rejected request, and add a raw-protocol client to the test suite so the "impossible" sequences are sent on purpose.
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 | assert |
| Seen in | production-node, functional-test |
| Scope | production |
| Trigger | Concurrent calls (concurrent-calls) |
| Phase | steady-state |
| Side | server |
| Component | proxy-io |
| Root cause kind | assumption-violated |
| Fix kind | local-fix |
| Detection | ci-intermittent |
| Needs a peer that is not libmultiprocess C++ | yes |
| Needed a sanitizer to see | no |
| Defect ever on master | yes |
| Released | binary |
| Confidence | high |