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);
}
[0.5s] Cancel clicked
[3.1s] Report saved (10/10 rows)
[3.1s] (done)
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);
}
[0.5s] Cancel clicked
[0.6s] Cancelled cleanly
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;
}
[0.5s] Cancel clicked
[3.0s] Loaded prices
[3.0s] (done)
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);
[0.5s] Cancel clicked
[0.5s] Cancelled cleanly
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
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);
}
[1.0s] Cancel clicked
[1.2s] Report saved (4/10 rows)
[1.2s] (done)
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++;
}
[1.0s] Cancel clicked
[1.0s] Cancelled cleanly
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);
}
}
[1.0s] Cancel clicked
Unhandled exception. System.OperationCanceledException: The operation was canceled.
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");
}
[1.0s] Cancel clicked
[1.2s] Search cancelled
[1.2s] (done)
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");
}
[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)
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}");
}
[0.1s] Cancel clicked
[0.1s] Cancelled cleanly
Recap
- The token you give
Task.Runonly prevents the start. Pass it into the work. - Forward the token to every call that accepts one. Turn on CA2016.
-
ThrowIfCancellationRequested(), not a quietbreak. - Catch
OperationCanceledException, notTaskCanceledException. - 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)