Skip to content

Fix experimental::promise cancellation - #1767

Open
killerdevildog wants to merge 2 commits into
chriskohlhoff:masterfrom
killerdevildog:fix/promise-cancel-post-to-executor
Open

Fix experimental::promise cancellation#1767
killerdevildog wants to merge 2 commits into
chriskohlhoff:masterfrom
killerdevildog:fix/promise-cancel-post-to-executor

Conversation

@killerdevildog

Copy link
Copy Markdown

Fix experimental::promise cancellation

Fixes #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 handler
directly:

void operator()(cancellation_type level) const
{
  if (auto p = self.lock())
  {
    p->cancel.emit(level);
    p->cancel_();          // completes inline
  }
}

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 via asio::dispatch(impl_->executor, ...), and the already-completed branch of initiate_async_wait::operator()
posts through get_associated_executor(handler, self_->get_executor()). Only
the cancellation-slot path completed inline.

Separately, initiate_async_wait exposed neither executor_type nor
get_executor(), which cancel_after requires. That combination did not
compile 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 a
cancellation slot directly:

#include <asio.hpp>
#include <asio/experimental/promise.hpp>
#include <asio/experimental/use_promise.hpp>
#include <chrono>
#include <cstdio>

using namespace std::chrono_literals;

int main()
{
  asio::io_context ctx;
  asio::cancellation_signal sig;

  bool ran_during_emit = false;
  bool emitting = false;

  asio::steady_timer never(ctx, 1h);
  auto p = never.async_wait(asio::experimental::use_promise);

  std::move(p)(asio::bind_cancellation_slot(sig.slot(),
      [&](std::error_code ec)
      {
        ran_during_emit = emitting;
        std::printf("handler ran, ec=%s\n", ec.message().c_str());
      }));

  emitting = true;
  sig.emit(asio::cancellation_type::all);   // from outside the io_context
  emitting = false;

  std::printf("ran synchronously inside emit(): %s\n",
      ran_during_emit ? "YES" : "no");

  never.cancel();
  ctx.run();
  return ran_during_emit ? 1 : 0;
}
master  : handler ran, ec=Operation aborted.
          ran synchronously inside emit(): YES

this PR : ran synchronously inside emit(): no
          handler ran, ec=Operation aborted.

The output order changes because the handler now runs during ctx.run()
rather than inside emit().

Commit 1 — Peter Eisenlohr

  • Adds executor_type and get_executor() to initiate_async_wait, so
    cancel_after works with a promise.
  • Removes the inline p->cancel_() call.
  • Adds test_cancel_after to src/tests/unit/experimental/promise.cpp.

Commit 2 — restore the forced completion, posted

Removing p->cancel_() outright reintroduces the problem it was added to
solve. 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:

struct deaf_init
{
  using executor_type = asio::any_io_executor;
  std::shared_ptr<asio::steady_timer> t;
  executor_type ex;
  executor_type get_executor() const noexcept { return ex; }

  template <typename Handler>
  void operator()(Handler&& handler) const
  {
    // forwards to an inner wait, dropping the outer handler's slot
    t->async_wait([h = std::forward<Handler>(handler)](std::error_code) mutable
        { std::move(h)(std::error_code{}); });
  }
};

auto p = asio::async_initiate<
    decltype(asio::experimental::use_promise), void(std::error_code)>(
        deaf_init{t, ctx.get_executor()}, asio::experimental::use_promise);

std::move(p)(asio::bind_cancellation_slot(sig.slot(),
    [&](std::error_code){ called = true; }));

asio::post(ctx, [&]{ sig.emit(asio::cancellation_type::all); });

The context is stopped well before the underlying timer would fire:

master commit 1 only this PR
handler completes on cancel yes no, pending yes

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 with
std::exchange(completion, nullptr); the second reaches complete() again and
dereferences null.

This is not a debug-only assertion. Built with -O2 -DNDEBUG:

sig.emit(asio::cancellation_type::partial);
sig.emit(asio::cancellation_type::total);
master  : Segmentation fault (core dumped)   [rc=139, 1.08s]
this PR : handler invoked 1 time(s)          [rc=0, 2ms]

A debug build trips Assertion 'completion' failed at impl/promise.hpp:144.
Escalating from partial to total is a documented cancellation pattern, so
this is reachable from ordinary use. The if (p->completion) guards in commit
2 prevent it.

Results

check master commit 1 this PR
cancel_after compiles with a promise no yes yes
handler invoked inline inside emit() yes no no
double emit segfault ok ok
operation ignoring cancellation completes yes no yes

History

experimental::promise was added in 7e3d996 (asio 1.19.0, 2021-06-20). Its
cancellation 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 active
consideration 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:

Commit Release Date Subject
7e3d99643 1.19.0 2021-06-20 Added experimental::promise.
6d48fccb2 1.23.0 2022-04-27 Removed all & race from promise.
00e5b6aaa 1.26.0 2022-08-28 Cleaned up promise and made it an async_op.
8d176a2c8 1.27.0 2023-02-25 Update copyright notices.
5a45f7b37 1.31.0 2024-02-27 Update examples to use deferred as the default.
79f17e970 1.31.0 2024-06-27 Clean up spurious white space.
5503632ee 1.38.0 2025-08-21 Rearrange directories to match distribution tarball.
8f1ecef92 1.38.1 2026-03-01 Add optional binary versioning using an inline namespace.
64b353342 1.38.2 2026-07-07 Augment the reference with links to the overview docs.

Behaviour changes

Both follow from the handler no longer running inline.

A throwing completion handler now surfaces from run() rather than from
emit(). Previously the exception escaped cancellation_signal::emit(), which
may sit in a destructor or another handler.

If cancellation is emitted while the io_context is stopped, the handler is
deferred rather than run immediately. It is not lost — the posted function
holds a shared_ptr to the implementation, and the handler is invoked when the
context is destroyed.

Unchanged: cancelling from within a handler running on the io_context, and
cancelling a promise whose context is never run.

Validation

src/tests/unit/experimental/promise.cpp passes, including the existing
test_cancel and Eisenlohr's new test_cancel_after. parallel_group,
awaitable_operators, channel and co_composed also pass.

Built with GCC 15, -std=c++20, standalone asio on Linux, in both default and
-O2 -DNDEBUG configurations. 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 a
different 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.

pgit and others added 2 commits September 12, 2026 11:42
…_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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

awaiting an experimental::promise with "cancel_after" doesn't post() completion on cancellation

2 participants