Audit report AR-71
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
Summary
When an IPC request is handed to a worker thread, libmultiprocess chains it behind a "thread ready" signal, and the code that fires the next signal lives inside the promise returned to the caller. If a caller dropped that promise before it ran, the signal would be rejected and every later request on that worker thread would fail. The mechanism is real, but nothing on master can drop the promise while the worker thread is still usable: the only place it happens is when the connection is torn down, and the worker thread goes away with it. Hardening, not a bug that can be hit today.
The report
ProxyServer<Thread>::post() returns a lazy continuation that does two jobs: it hands the request to the worker thread, and it later fulfills the thread's ready signal (m_thread_ready) so the next request can be handed over. If the caller drops the returned promise before the continuation runs, the continuation and the only fulfiller of the ready signal are destroyed, the signal is rejected, and later requests to that worker never run.
The report rates it low. A unit test with a one-thread pool drops the result of one post() call and shows that a second call does not complete within five seconds. It suggests a disconnect before the request is dispatched as a natural cause, but did not force that timing, and says the effect lasts until the connection is replaced. The trigger needs a local IPC client or library caller; no crash or node-wide effect was shown.
Proposed change
Create the result promise outside the continuation, register the continuation with the event loop's task set so it always runs, and return the result promise with the cancellation probe attached.
auto ready = kj::newPromiseAndFulfiller<void>();
+ auto result = kj::newPromiseAndFulfiller<T>();
...
- return kj::mv(result.promise);
- }).attach(kj::heap<CancelProbe>(cancel_monitor));
+ });
+ m_loop->m_task_set->add(kj::mv(ret));
m_thread_ready = kj::mv(ready.promise);
- return ret;
+ return kj::mv(result.promise).attach(kj::heap<CancelProbe>(cancel_monitor));
The new test also checks that a move-only result still comes back. The report considered catching and ignoring the rejected ready signal, and rejected it because the earlier request would then not be guaranteed to reach the worker, which could break the rule of one pending request per thread.
Assessment
The mechanism is accurate. On libmultiprocess master the next ready fulfiller is captured by the continuation, kj promises do nothing until someone waits on them, and kj rejects a promise whose fulfiller is discarded. Once the ready signal is rejected, each later request on that thread proxy is rejected in turn, so reading the code, later requests fail with an error rather than hang. The ready signal was added by mp#240 (merged 2026-02-20, in Bitcoin Core v31.0 and later).
It cannot be reached on master:
- The only callers of
post()pass its promise straight back to Cap'n Proto. Cap'n Proto ignores a client's request to cancel a call unless the method is marked as cancellable, and no Bitcoin Core or libmultiprocess method is. So a Python or Rust client that cancels a request does not cause the drop. - The only code that drops the promise is the connection's own cleanup, which cancels all pending requests on that connection when it is destroyed. Worker threads, whether made for the client or taken from a pool, belong to that connection, so no later request can reach the broken thread proxy. The report's "until the connection is replaced" and its trigger are the same event.
It would become a real stall if methods were ever made cancellable, or if new code called post() and discarded the result while the connection was alive. The fix reads as correct and keeps cancellation detection: a dropped request still reaches the worker, which sees it was cancelled and skips it. It is a sensible change to make before any cancellation support is added. A related path, where a failed post rejects the same ready signal, is described in AR-73.
Status
Present on master. Hardening; no public issue or PR yet.
Reported privately in an independent security review on 2026-09-28.