Fix experimental::promise cancellation - #1767
Open
killerdevildog wants to merge 2 commits into
Open
Conversation
…_after doesn't post() completion on cancellation
An operation that does not act on the cancellation signal would otherwise leave the promise pending indefinitely. Post the completion through the promise's executor rather than invoking it inline, and guard against the operation having already completed.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fix
experimental::promisecancellationFixes #1705.
This PR contains two commits. The first is Peter Eisenlohr's fix from
pgit/asio@f821cc0, which he pushed to his fork on 2026-01-19 and referenced
from #1705 without opening a PR. It is included here unmodified, with his
authorship. The second commit adjusts one part of it, for the reason set out
below.
The defect
promise's cancellation handler invoked the user's completion handlerdirectly:
A cancellation signal may be emitted from any thread, and from within an
initiating function, so the handler could run outside its associated executor —
on whichever thread called
emit(), while that call was still on the stack.Two other paths in the same header already route through an executor.
promise::cancel()dispatches the signal viaasio::dispatch(impl_->executor, ...), and the already-completed branch ofinitiate_async_wait::operator()posts through
get_associated_executor(handler, self_->get_executor()). Onlythe cancellation-slot path completed inline.
Separately,
initiate_async_waitexposed neitherexecutor_typenorget_executor(), whichcancel_afterrequires. That combination did notcompile at all, which is why #1705's own reproduction cannot be built against
master.
Reproduction
The inline invocation is observable without
cancel_after, by binding acancellation slot directly:
The output order changes because the handler now runs during
ctx.run()rather than inside
emit().Commit 1 — Peter Eisenlohr
executor_typeandget_executor()toinitiate_async_wait, socancel_afterworks with a promise.p->cancel_()call.test_cancel_aftertosrc/tests/unit/experimental/promise.cpp.Commit 2 — restore the forced completion, posted
Removing
p->cancel_()outright reintroduces the problem it was added tosolve. It was introduced by 00e5b6a ("Cleaned up promise and made it an
async_op", asio 1.26.0) so that an operation which does not act on the
cancellation signal would still complete the promise. With the call removed,
such an operation leaves the promise pending indefinitely and the handler is
never invoked.
Reproduced with an operation whose initiation drops the cancellation slot, so
p->cancel.emit()reaches nothing:The context is stopped well before the underlying timer would fire:
The second commit therefore restores the forced completion, but posts it
through the promise's executor instead of invoking it inline, which addresses
the original defect without losing the guarantee.
A crash this also fixes
Emitting cancellation more than once segfaults on master. The first emission
completes the promise and
complete()clears the pointer withstd::exchange(completion, nullptr); the second reachescomplete()again anddereferences null.
This is not a debug-only assertion. Built with
-O2 -DNDEBUG:A debug build trips
Assertion 'completion' failedatimpl/promise.hpp:144.Escalating from
partialtototalis a documented cancellation pattern, sothis is reachable from ordinary use. The
if (p->completion)guards in commit2 prevent it.
Results
cancel_aftercompiles with a promiseemit()History
experimental::promisewas added in 7e3d996 (asio 1.19.0, 2021-06-20). Itscancellation handler emitted the signal and nothing else. The forced completion
arrived in 00e5b6a (asio 1.26.0, 2022-08-28), which added
p->cancel_()without an executor hop — the same commit added
#include "asio/dispatch.hpp"and left
promise::cancel()dispatching, so executor context was under activeconsideration and this call site appears to have been overlooked.
The line has not changed since. Every subsequent commit to the file has been
non-functional with respect to this path:
7e3d996436d48fccb200e5b6aaa8d176a2c85a45f7b3779f17e9705503632ee8f1ecef9264b353342Behaviour changes
Both follow from the handler no longer running inline.
A throwing completion handler now surfaces from
run()rather than fromemit(). Previously the exception escapedcancellation_signal::emit(), whichmay sit in a destructor or another handler.
If cancellation is emitted while the
io_contextis stopped, the handler isdeferred rather than run immediately. It is not lost — the posted function
holds a
shared_ptrto the implementation, and the handler is invoked when thecontext is destroyed.
Unchanged: cancelling from within a handler running on the
io_context, andcancelling a promise whose context is never run.
Validation
src/tests/unit/experimental/promise.cpppasses, including the existingtest_canceland Eisenlohr's newtest_cancel_after.parallel_group,awaitable_operators,channelandco_composedalso pass.Built with GCC 15,
-std=c++20, standalone asio on Linux, in both default and-O2 -DNDEBUGconfigurations. Windows and the Boost build were not tested.The four checks in the results table above were run as standalone programs
against master, commit 1 alone, and this PR. They are not included here.
Known limitation
The forced completion is posted to the promise's executor. The
already-completed path uses the handler's associated executor with the
promise's as a fallback, but the cancellation handler cannot reach it — by that
point the handler is type-erased inside
completion_impl. A handler bound to adifferent executor will run on the promise's executor rather than its own.
Closing that gap would mean capturing the associated executor in
set_completion, which is a larger change than this fix warrants.