DEV Community

Cover image for 5 ways Cancel doesn't actually cancel in C#
Naze Code
Naze Code

Posted on

5 ways Cancel doesn't actually cancel in C#

A user clicks Export, changes their mind, and clicks Cancel. The spinner goes away. On the server, the export keeps running to the end and saves a file nobody wants.

CancellationToken only works if every piece of code along the way cooperates. Here are five ways it quietly doesn't. Each demo logs a timestamp, the "Cancel" click is simulated, and every output is copied from actually running it on .NET 10. (The demo harness prints Cancelled cleanly when the task ends with a cancellation, and (done) when it runs to completion.)

1. Passing the token to Task.Run and nowhere else

using var cts = new CancellationTokenSource();
var work = Task.Run(() => BuildReport(), cts.Token);

await Task.Delay(500);
cts.Cancel();                     // user clicks Cancel
Log.Write("Cancel clicked");

await work;

static void BuildReport()
{
    for (var row = 1; row <= Report.Rows; row++)
        Thread.Sleep(300);        // slow work, row by row
    Report.Save(Report.Rows);
}
Enter fullscreen mode Exit fullscreen mode
[0.5s] Cancel clicked
[3.1s] Report saved (10/10 rows)
[3.1s] (done)
Enter fullscreen mode Exit fullscreen mode

Cancelled at half a second, finished anyway at three. The token you give Task.Run is only used to skip the work if it hasn't started yet. Once BuildReport is running, nothing inside it knows about the token.

Fix: pass the token into the work and check it.

var work = Task.Run(() => BuildReport(cts.Token), cts.Token);

static void BuildReport(CancellationToken ct)
{
    for (var row = 1; row <= Report.Rows; row++)
    {
        ct.ThrowIfCancellationRequested();
        Thread.Sleep(300);        // slow work, row by row
    }
    Report.Save(Report.Rows);
}
Enter fullscreen mode Exit fullscreen mode
[0.5s] Cancel clicked
[0.6s] Cancelled cleanly
Enter fullscreen mode Exit fullscreen mode

2. Taking a token and not passing it on

public static async Task<string> LoadPricesAsync(CancellationToken ct)
{
    var prices = await Api.FetchAsync("prices");
    return prices;
}
Enter fullscreen mode Exit fullscreen mode
[0.5s] Cancel clicked
[3.0s] Loaded prices
[3.0s] (done)
Enter fullscreen mode Exit fullscreen mode

The method accepts ct, so it looks cancellable. But FetchAsync, like most .NET APIs, takes the token as an optional parameter, so forgetting it compiles without a word.

Fix: forward it.

var prices = await Api.FetchAsync("prices", ct);
Enter fullscreen mode Exit fullscreen mode
[0.5s] Cancel clicked
[0.5s] Cancelled cleanly
Enter fullscreen mode Exit fullscreen mode

There's an analyzer rule for exactly this, CA2016, but in my test it didn't show up in a normal build until I turned it on in .editorconfig:

dotnet_diagnostic.CA2016.severity = warning
Enter fullscreen mode Exit fullscreen mode

3. Stopping quietly instead of loudly

public static async Task ExportAsync(CancellationToken ct)
{
    var rows = 0;
    while (rows < Report.Rows)
    {
        if (ct.IsCancellationRequested) break;   // stop quietly
        await Task.Delay(300);
        rows++;
    }
    Report.Save(rows);
}
Enter fullscreen mode Exit fullscreen mode
[1.0s] Cancel clicked
[1.2s] Report saved (4/10 rows)
[1.2s] (done)
Enter fullscreen mode Exit fullscreen mode

It did stop, but break drops straight into the code after the loop, which saves a partial report as if it were finished. And the caller sees a normal successful return, so it can't tell the export was cancelled.

Fix: throw, and give the token to the delay too, so a cancel interrupts the wait instead of finishing it.

while (rows < Report.Rows)
{
    ct.ThrowIfCancellationRequested();       // stop loudly
    await Task.Delay(300, ct);
    rows++;
}
Enter fullscreen mode Exit fullscreen mode
[1.0s] Cancel clicked
[1.0s] Cancelled cleanly
Enter fullscreen mode Exit fullscreen mode

4. Catching the wrong exception type

try
{
    await SearchAsync(ct);
}
catch (TaskCanceledException)
{
    Log.Write("Search cancelled");
}

static async Task SearchAsync(CancellationToken ct)
{
    foreach (var page in Enumerable.Range(1, 10))
    {
        ct.ThrowIfCancellationRequested();
        await Pages.LoadAsync(page);
    }
}
Enter fullscreen mode Exit fullscreen mode
[1.0s] Cancel clicked
Unhandled exception. System.OperationCanceledException: The operation was canceled.
Enter fullscreen mode Exit fullscreen mode

Cancellation comes in two exception types. A cancelled Task.Delay (and many async APIs) throws TaskCanceledException. ThrowIfCancellationRequested() throws its base class, OperationCanceledException. Catching the narrower one lets the other crash straight through.

Fix: catch the base class, which covers both.

catch (OperationCanceledException)
{
    Log.Write("Search cancelled");
}
Enter fullscreen mode Exit fullscreen mode
[1.0s] Cancel clicked
[1.2s] Search cancelled
[1.2s] (done)
Enter fullscreen mode Exit fullscreen mode

5. A retry loop that retries cancellation

public static async Task SendWithRetryAsync(CancellationToken ct)
{
    for (var attempt = 1; attempt <= 5; attempt++)
    {
        try
        {
            await Api.SendAsync(ct);
            return;
        }
        catch (Exception e)
        {
            Log.Write($"Attempt {attempt} failed: {e.Message}");
        }
    }
    Log.Write("ALERT: order could not be sent");
}
Enter fullscreen mode Exit fullscreen mode
[0.1s] Attempt 1 failed: A task was canceled.
[0.1s] Cancel clicked
[0.1s] Attempt 2 failed: A task was canceled.
[0.1s] Attempt 3 failed: A task was canceled.
[0.1s] Attempt 4 failed: A task was canceled.
[0.1s] Attempt 5 failed: A task was canceled.
[0.1s] ALERT: order could not be sent
[0.1s] (done)
Enter fullscreen mode Exit fullscreen mode

The user cancelled, and the code treated it as an outage: five instant "failures" and an alert that pages someone. catch (Exception) catches cancellation too.

Fix: an exception filter, so cancellation passes straight through the retry.

catch (Exception e) when (e is not OperationCanceledException)
{
    Log.Write($"Attempt {attempt} failed: {e.Message}");
}
Enter fullscreen mode Exit fullscreen mode
[0.1s] Cancel clicked
[0.1s] Cancelled cleanly
Enter fullscreen mode Exit fullscreen mode

Recap

  1. The token you give Task.Run only prevents the start. Pass it into the work.
  2. Forward the token to every call that accepts one. Turn on CA2016.
  3. ThrowIfCancellationRequested(), not a quiet break.
  4. Catch OperationCanceledException, not TaskCanceledException.
  5. Retry loops must let cancellation through: when (e is not OperationCanceledException).

Which of these is hiding in your codebase right now? Tell me in the comments.

I make short, tested videos about C# bugs that compile fine and still break things. More on the Naze Code YouTube channel.


Sources: Microsoft Learn, Cancellation in managed threads, Task.Run, CA2016, TaskCanceledException. Tested on .NET SDK 10.0.302.

Top comments (0)