Bug 4
Cancellation flag is written unlocked because its lock was a temporary
- Status
- Fixed
- Symptom
- Data race; worst effect seen: nothing observed
- Where seen
- Never observed
- Trigger
- A client disconnects abruptly, for example because it is killed during a call
- First reported
- 2026-02-24 (first report)
- Fixed
- 2026-02-25
- Fix
- mp#245
- Shipped to Bitcoin Core
- bitcoin#34422
- In released binaries
- No
- Defect ever on master
- Yes
Summary
The request cancellation added in mp#240 lets the server stop work on a call whose client has disconnected. Its cancellation callback took its mutex as an unnamed temporary, so the lock was released immediately and the request_canceled = true write that followed ran unlocked. In theory a worker thread could then miss a cancel and write results into structs Cap'n Proto was about to free. It was found in code review, was never observed in practice, and was fixed in mp#245 five days after mp#240 merged, before it reached Bitcoin Core or any release.
What happened
Never observed. @janb84 was reviewing the subtree update bitcoin#34422, which carried mp#240 into Bitcoin Core, and asked (2026-02-24): "Lock{cancel_mutex}; is an unnamed temporary ... Leaving the server_context.request_canceled = true; without a lock?" ryanofsky agreed it was a theoretical race and opened mp#245 within the hour.
Root cause
In PassField (include/mp/type-context.h), the m_on_cancel callback runs on the event loop thread when a request is cancelled. It is meant to wait on cancel_mutex while the worker thread is reading the request's parameters or writing its results, then mark the request cancelled:
Lock{cancel_mutex};
server_context.request_canceled = true;
Lock{cancel_mutex}; creates and destroys a temporary, so the mutex is released at the semicolon. In an earlier revision of mp#240 request_canceled was a std::atomic<bool> assigned before the lock, and an unnamed lock was enough. The final revision made it a plain bool protected by cancel_mutex but kept the unnamed lock. The worker holds cancel_mutex except while the method body runs: ServerCall::invoke (include/mp/proxy-types.h) unlocks it around the call and retakes it before the results are written. So when a cancel lands while the method runs, Lock{cancel_mutex} only waits until the worker has dropped the lock. The event loop then writes request_canceled with no lock held while the worker, its method returned, retakes the lock and reads the flag. That is a data race that can read a stale false, after which the worker fills in results that Cap'n Proto frees as soon as m_on_cancel returns.
The fix's commit message describes the window as the event loop cancelling "the request before the execution thread runs" and the worker then accessing "deleted parameters". The code is narrower: m_on_cancel is only registered once the worker has started running the request (an earlier cancel is caught by a separate check on the event loop), and what the worker touches afterwards is the results. The commit message calls it "very unlikely to occur in practice".
The code
Quoted from e0f1cd762190^. PassField (include/mp/type-context.h) installs this cancellation callback, which the event-loop thread runs if the request is cancelled while the worker thread is executing it:
cancel_monitor.m_on_cancel = [&server, &server_context, &cancel_mutex, req]() {
MP_LOG(*server.m_context.loop, Log::Info) << "IPC server request #" << req << " canceled while executing.";
// ...
Lock{cancel_mutex};
server_context.request_canceled = true;
};
On the worker thread, ServerCall::invoke (include/mp/proxy-types.h) releases the worker's cancel_lock while the method runs, then retakes it and checks the flag before the results are written:
if (server_context.cancel_lock) server_context.cancel_lock->m_lock.unlock();
return TryFinally(
// ...
[&] {
if (server_context.cancel_lock) server_context.cancel_lock->m_lock.lock();
// ...
if (server_context.request_canceled) throw InterruptException{"canceled"};
});
Lock{cancel_mutex}; builds a temporary that locks and unlocks on the same line, so request_canceled (a plain bool) is written with no lock held:
- Worker: releases
cancel_lockand runs the method. - Event loop: the client disconnects;
m_on_canceltakes and immediately dropscancel_mutex. - Worker: the method returns; it retakes
cancel_lockand readsrequest_canceledwhile the event loop is writing it, a data race that can seefalse. - Event loop: returns from
m_on_canceland Cap'n Proto frees the params and results the worker is about to fill.
The fix names the lock so it is held until the end of the lambda (e0f1cd762190):
@@ -123,7 +123,7 @@ auto PassField(Priority<1>, TypeList<>, ServerContext& server_context, const Fn&
- Lock{cancel_mutex};
+ Lock cancel_lock{cancel_mutex};
server_context.request_canceled = true;
};
The write now happens under cancel_mutex, so a worker that holds cancel_lock either sees true or has already finished with the structs before the event loop can go on to free them.
Fix
The fix commit e0f1cd762190 names the lock, Lock cancel_lock{cancel_mutex};, so it covers the assignment (+1/-1).
Timeline
- 2026-02-20: introduced by 0174450ca2e9, merged in mp#240 ("Avoid errors from asynchronous (non-c++) clients"). The plain-
boolchange was made to that commit during review, on 2026-02-18. - 2026-02-24: found by @janb84 in review of bitcoin#34422.
- 2026-02-25: fix merged in mp#245.
- 2026-03-03: bitcoin#34422 merged into Bitcoin Core master with both mp#240 and the fix, so Bitcoin Core never had the bug.
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 Locks, waits and wakeups: A field locked by some accessors and not others
- Prevented by the type system, a review checklist item
- Earliest detector static analysis (did not exist then)
- Diagnosis easy
- Fix design easy
- Rust yes
Would Rust have prevented it? Yes. The flag would live inside the Mutex and could only be written through a live guard, so a lock temporary that dies on the same line has no equivalent: let _ = m.lock() gives nothing to write through, and clippy's let_underscore_lock lint flags the attempt. More on Rust
Could it have been prevented?
Yes, cheaply. Lock{cancel_mutex}; is a temporary that locks and unlocks on one line;
the language accepts it silently because the object has a destructor. A [[nodiscard]]
on the Lock type (or its constructor, in C++20) turns the line into a compiler
warning, and the review comment that found it is the checklist item: an RAII guard
that is not named is not held. The deeper cause is a revision slip inside one PR:
request_canceled was an std::atomic<bool> assigned before the lock, then became a
plain bool guarded by the lock on 2026-02-18, and the unnamed lock survived the
change (e0f1cd762190
tells that history). A flag reachable only through an accessor that takes a Lock&,
the shape the same file already uses for GuardedRef, would have refused to compile
the unlocked write.
Could it have been found sooner?
By clang-tidy's bugprone-unused-raii, which exists to flag exactly this construct;
neither project's .clang-tidy enables it, and Bitcoin Core's tidy job does not cover
the subtree. It was found five days after
mp#240 merged, by janb84
reading the diff
of the subtree update rather than the library PR, which is the cost of the
two-repository layout: the change had been reviewed once already. TSan is the other
candidate, since the unlocked write and the worker's later locked read are
unsynchronized, but only when a test cancels a request mid-execution and the worker's
wake-up does not itself order the read after the write; the PR's TSan runs did not
report it, and no one has reproduced it since.
What made it hard to debug?
Nothing: it was diagnosed from the source line and fixed by naming the variable (+1/-1) within the hour. It belongs in the ledger as the first of three regressions from the cancellation mechanism, all found in review or by sanitizer within three weeks of merging, and as a reminder that a PR whose synchronization design changes between revisions needs its earlier revisions' assumptions re-read, not just the final diff.
Lesson
An RAII lock must be a named variable, and the compiler can enforce that; when a field changes from atomic to lock-protected during review, every existing access to it is a new line to review.
Classification
The survey's classification of this bug. The explorer charts these fields across all bugs; the method page explains them.
| Field | Value |
|---|---|
| Symptom | data-race |
| Worst effect observed | none |
| Seen in | never |
| Scope | production |
| Trigger | Client disconnects abruptly (client-unclean-disconnect) |
| Phase | steady-state |
| Side | server |
| Component | type-context |
| Root cause kind | missing-lock |
| Fix kind | one-line-tweak |
| Detection | review |
| Needs a peer that is not libmultiprocess C++ | yes |
| Needed a sanitizer to see | no |
| Defect ever on master | yes |
| Released | no |
| Confidence | high |