Skip to content

Fix race condition in cancellation setup/teardown#1609

Open
oldnewthing wants to merge 1 commit into
masterfrom
user/raymondc/cancellation-race
Open

Fix race condition in cancellation setup/teardown#1609
oldnewthing wants to merge 1 commit into
masterfrom
user/raymondc/cancellation-race

Conversation

@oldnewthing

Copy link
Copy Markdown
Member

The code failed to handle three cases.

  1. A cancellation is in progress when a cancellable awaitable begins.
  2. A cancellation is in progress when a cancellable awaitable ends.
  3. A cancellation is in progress when a cancel() request is made.

The m_canceller member has one of these three values:

  • nullptr, meaning that there is nothing to cancel. It has this value when the coroutine is not awaiting, or if it is awaiting something that cannot be cancelled.
  • cancelling_ptr, meaning that another thread (not the coroutine thread) is in the middle of cancellation request.
  • function pointer, representing the function to call to cancel the await.

In case 1, we should not overwrite the canceling_ptr with the function pointer, because only the code doing the cancel() can transition into/out of cancelling_ptr.

In case 2, we intended to spin until the m_canceller is no longer cancelling_ptr, but we used m_canceller.exchange(nullptr) in a loop, which means that if m_canceller was cancelling_ptr, we overwrite it with nullptr. As a result, the "while" loop always exits after one iteration. We need to spin on the m_canceller without modifying it if it is cancelling_ptr.

In case 3, cancel() function resets m_cancelling back to nullptr, even if the value was cancelling_ptr on entry, prematurely declaring that the existing cancel() has completed. If the original value was cancelling_ptr, we should leave it that way.

There are still other cases not handled:

  • Coroutine already cancelled when a co_await starts.

In this case, we never call the canceller, so the coroutine fails to propagate cancellation. This will require a broader fix, so I'm not going to fix it in this PR. This PR is primarily about fixing the crash caused by case 2. Cases 1 and 3 were fixed opportunistically.

The code failed to handle three cases.

1. A cancellation is in progress when a cancellable awaitable begins.
2. A cancellation is in progress when a cancellable awaitable ends.
3. A cancellation is in progress when a cancel() request is made.

The m_canceller member has one of these three values:

* nullptr, meaning that there is nothing to cancel.
  It has this value when the coroutine is not awaiting, or if it is
  awaiting something that cannot be cancelled.
* cancelling_ptr, meaning that another thread (not the coroutine thread)
  is in the middle of cancellation request.
* function pointer, representing the function to call to cancel the await.

In case 1, we should not overwrite the canceling_ptr with the
function pointer, because only the code doing the cancel()
can transition into/out of cancelling_ptr.

In case 2, we intended to spin until the m_canceller is no longer
cancelling_ptr, but we used m_canceller.exchange(nullptr) in a loop,
which means that if m_canceller was cancelling_ptr, we overwrite it
with nullptr. As a result, the "while" loop always exits after one iteration.
We need to spin on the m_canceller without modifying it if it is cancelling_ptr.

In case 3, cancel() function resets m_cancelling back to nullptr,
even if the value was cancelling_ptr on entry, prematurely declaring
that the existing cancel() has completed. If the original value was
cancelling_ptr, we should leave it that way.

There are still other cases not handled:

* Coroutine already cancelled when a co_await starts.

In this case, we never call the canceller, so the coroutine fails
to propagate cancellation. This will require a broader fix, so I'm not
going to fix it in this PR. This PR is primarily about fixing the crash
caused by case 2. Cases 1 and 3 were fixed opportunistically.
Copilot AI review requested due to automatic review settings July 22, 2026 04:48
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
There may be pipelines that require an authorized user to comment /azp run to run.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes race conditions in coroutine cancellation propagation by tightening the state machine around cancellable_promise::m_canceller so cancellation setup/teardown and overlapping Cancel() calls don’t corrupt the atomic state (addressing the crash described as “case 2” and opportunistically improving cases 1 and 3).

Changes:

  • set_canceller now uses a CAS from nullptr to avoid overwriting the cancelling_ptr sentinel during an in-flight cancellation.
  • revoke_canceller now waits for cancelling_ptr to clear without clobbering it, and then clears the canceller via CAS.
  • cancel no longer resets m_canceller back to nullptr when it detects another cancellation is already in progress.

Comment thread strings/base_coroutine_threadpool.h
Comment on lines +155 to +159
auto existing = m_canceller.load(std::memory_order_relaxed);
do
{
std::this_thread::yield();
while (existing == cancelling_ptr)
{
@oldnewthing

Copy link
Copy Markdown
Member Author

Turns out 2 of the 3 fixes were independently discovered and fixes proposed in #1599.

@Clyraz

Clyraz commented Jul 22, 2026

Copy link
Copy Markdown

Doesn't this have the same bug as before?
In my pull request #1599 i set expected to nullptr after yielding but your code loads the current value.

I also noticed that cancellation propagation fails when the couroutine was already cancelled.
I think we should introduce a new state "cancelled_ptr" and immediatly invoke the canceller in set_canceller if the previous value wasn't nullptr.

@oldnewthing

Copy link
Copy Markdown
Member Author

Doesn't this have the same bug as before? In my pull request #1599 i set expected to nullptr after yielding but your code loads the current value.

@Clyraz My version spins in the inner while loop until the value changes to something other than cancelling_ptr. (Whereas your version uses an if and falls through to the compare-exchange with an optimistic value.)

I also noticed that cancellation propagation fails when the couroutine was already cancelled. I think we should introduce a new state "cancelled_ptr" and immediatly invoke the canceller in set_canceller if the previous value wasn't nullptr.

Yes, this is the troublesome "case 4" mentioned in the PR description that will require a broader fix.

@Clyraz

Clyraz commented Jul 22, 2026

Copy link
Copy Markdown

Doesn't this have the same bug as before? In my pull request #1599 i set expected to nullptr after yielding but your code loads the current value.

@Clyraz My version spins in the inner while loop until the value changes to something other than cancelling_ptr. (Whereas your version uses an if and falls through to the compare-exchange with an optimistic value.)

I also noticed that cancellation propagation fails when the couroutine was already cancelled. I think we should introduce a new state "cancelled_ptr" and immediatly invoke the canceller in set_canceller if the previous value wasn't nullptr.

Yes, this is the troublesome "case 4" mentioned in the PR description that will require a broader fix.

Yeah I just realized upon further look, stupid me.
Looks good.

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.

4 participants