Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [1.27.177] - 2026-07-21

### Fixed

- **CC016 / CC017** false negatives: mentioning a token only inside `nameof(...)` no longer counts
as observing cancellation. `nameof` is evaluated at compile time, so these methods still ignore
their runtime cancellation signal and are now diagnosed.

## [1.27.176] - 2026-07-21

### Fixed
Expand Down
9 changes: 6 additions & 3 deletions docs/ANALYZER_HEALTH.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Analyzer Health

Reviewed: 2026-07-21 (refreshed through the v1.27.176 hardening loop)
Reviewed: 2026-07-21 (refreshed through the v1.27.177 hardening loop)

A deliberately harsh health audit for the twenty-eight implemented CancelCop rule IDs (CC001–CC006, CC009–CC028).
Scores are 1–5, where `5` means reference-quality and hard to improve, `3` means usable but
Expand Down Expand Up @@ -51,8 +51,8 @@ Calibration notes:
| CC020 | gRPC method ignores `ServerCallContext.CancellationToken` | Usage | Warning | 4 | 4 | n/a | 4 | 3 | 3 | Low | **v1.15.0 (new):** flags a method with a `Grpc.Core.ServerCallContext` parameter that does async work but never reads `context.CancellationToken` and never passes the context on. Fills a genuine gap — the token is a property, not a parameter, so CC002 can't see it (cf. CC017 for BackgroundService). Analyzer-only; gated by parameter type name+namespace (tests use a stub). |
| CC019 | Broad catch swallows `OperationCanceledException` | Usage | Info | 4 | 3 | 4 | 4 | 3 | 3 | Low | **v1.14.0 (new); fixes v1.17.0/v1.27.174/v1.27.175:** flags a catch-all/`catch (Exception)` with no `when` filter, over a `try` containing awaited work in the current function scope, whose body never rethrows. Covers explicit `await`, `await foreach`, and both `await using` forms; awaits inside nested local functions/lambdas are ignored because that deferred work does not execute in the `try` itself. Info because boundary handlers are sometimes intended. Conservative (filter/rethrow/specific-type/no-await all suppress). The fix inserts `if (ex is OperationCanceledException) throw;` (typed catches only). |
| CC018 | SignalR hub method missing `CancellationToken` | Usage | Warning | 4 | 4 | 4 | 4 | 3 | 4 | Low | **v1.13.0 (new):** SignalR analogue of CC005B. Flags a public non-static async method on a `Microsoft.AspNetCore.SignalR.Hub`/`Hub<T>` subclass without a token; excludes lifecycle overrides + externally-controlled signatures. Reuses the shared add-token-parameter fixer. Base-type gated by name+namespace (tests use a faithful Hub stub, no package). |
| CC017 | `BackgroundService.ExecuteAsync` ignores stopping token | Usage | Warning | 4 | 4 | n/a | 4 | 3 | 4 | Low | **v1.12.0 (new):** flags an `override` of `ExecuteAsync(CancellationToken)` on a `Microsoft.Extensions.Hosting.BackgroundService` subclass whose body never references the stopping token — the override case CC016 skips. Analyzer-only; token passed to a helper or observed in a loop counts as used. Framework-gated to BackgroundService by base-type walk. |
| CC016 | Unused `CancellationToken` parameter | Usage | Info | 4 | 4 | n/a | 4 | 3 | 3 | Low | **v1.11.0 (new):** flags a method/local function that does async work (has `await`) but never references its `CancellationToken` parameter; excludes externally-controlled signatures and sync bodies. Analyzer-only by design. A token used in a nested lambda/local function counts as used. **v1.27.11 (FP fix):** a token marked `[EnumeratorCancellation]` is excluded — the async-iterator infrastructure delivers a consumer's `WithCancellation` token to it, so it is observed even without a body reference (cf. CC011). |
| CC017 | `BackgroundService.ExecuteAsync` ignores stopping token | Usage | Warning | 4 | 4 | n/a | 4 | 3 | 4 | Low | **v1.12.0 (new); fix v1.27.177:** flags an `override` of `ExecuteAsync(CancellationToken)` on a `Microsoft.Extensions.Hosting.BackgroundService` subclass whose body never references the stopping token at runtime — the override case CC016 skips. A compile-time-only `nameof(stoppingToken)` does not count as observation. Analyzer-only; token passed to a helper or observed in a loop counts as used. Framework-gated to BackgroundService by base-type walk. |
| CC016 | Unused `CancellationToken` parameter | Usage | Info | 4 | 4 | n/a | 4 | 3 | 3 | Low | **v1.11.0 (new); fixes v1.27.11/v1.27.177:** flags a method/local function that does async work (has `await`) but never references its `CancellationToken` parameter at runtime; excludes externally-controlled signatures and sync bodies. Analyzer-only by design. A token used in a nested lambda/local function counts as used, while a compile-time-only `nameof(token)` does not. A token marked `[EnumeratorCancellation]` is excluded — the async-iterator infrastructure delivers a consumer's `WithCancellation` token to it, so it is observed even without a body reference (cf. CC011). |
| CC015 | Blocking on async code (sync-over-async) | Usage | Warning | 4 | 4 | 4 | 4 | 3 | 4 | Low | **v1.10.0 (new):** flags `.Result`/`.Wait()`/`.GetAwaiter().GetResult()` on a `Task`/`Task<T>`/`ValueTask` inside an `async` function; fixer rewrites to `await`. Symbol-resolved (a look-alike `.Result` on a non-task is ignored). Shares `IsInAsyncFunction` with CC013. |
| CC014 | `CancellationTokenSource` never disposed | Usage | Warning | 4 | 4 | 4 | 4 | 3 | 4 | Low | **v1.9.0 (new):** flags a local `new CancellationTokenSource(...)`/`CreateLinkedTokenSource(...)` that is not a `using` decl, never disposed, and never escapes (return/out-assign/argument/nested-capture); fixer converts to a `using` declaration. Conservative escape analysis — any disposal-elsewhere path suppresses it (like a scoped CA2000 for CTS). |
| CC013 | `Thread.Sleep` in async code | Usage | Warning | 4 | 4 | 4 | 4 | 3 | 4 | Low | **v1.8.0 (new):** flags `System.Threading.Thread.Sleep` lexically inside an `async` method/local function/lambda/anonymous method; fixer rewrites to `await Task.Delay(delay, token)` (token flowed when in scope). Async-context check stops at the first function boundary, so a synchronous lambda inside an async method is quiet. Symbol-resolved (no name-only match). |
Expand Down Expand Up @@ -168,6 +168,9 @@ Grading: **P0** = release-blocking; **P1** = next hardening loop; **P2** = oppor

## Verification Baseline

- v1.27.177: 632 tests, green locally. **CC016/CC017 FN fix:** token references contained only
within `nameof(...)` no longer suppress unused-token diagnostics because they have no runtime
cancellation effect; real references in nested lambdas and local functions remain observed.
- v1.27.176: 630 tests, green locally. **CC012 FP fix:** a custom token-valued property named
`None` is no longer treated as `CancellationToken.None`; the framework property must resolve by
symbol identity before the analyzer offers the in-scope token replacement.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
<PackageId>CancelCop.Analyzer</PackageId>
<!-- Fallback for local packs; the publish workflow overrides this with -p:Version from
the release tag so the published package always matches the tag. -->
<Version>1.27.176</Version>
<Version>1.27.177</Version>
<Authors>George Wall</Authors>
<Description>A surgical Roslyn analyzer focused on CancellationToken best practices: propagation, parameter positioning, loop cancellation checks, and more. Includes automatic code fixes for public APIs, handlers, EF Core, HTTP calls, and Minimal APIs.</Description>
<PackageTags>roslyn;analyzer;cancellationtoken;async;code-fix;loops;efcore;httpclient</PackageTags>
Expand Down
29 changes: 26 additions & 3 deletions src/CancelCop.Analyzer/CancellationTokenHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Operations;

namespace CancelCop.Analyzer;

Expand Down Expand Up @@ -297,9 +298,10 @@ public static bool IsInAsyncFunction(SyntaxNode node)
}

/// <summary>
/// Returns true when <paramref name="parameter"/> is referenced anywhere within
/// Returns true when <paramref name="parameter"/> is referenced at runtime within
/// <paramref name="body"/> — including inside nested lambdas and local functions, where the
/// parameter is captured. Used to tell an observed token from a dead one.
/// parameter is captured. Compile-time-only <c>nameof</c> references are ignored. Used to tell
/// an observed token from a dead one.
/// </summary>
public static bool IsParameterReferenced(
SyntaxNode body,
Expand All @@ -312,8 +314,29 @@ public static bool IsParameterReferenced(
if (identifier.Identifier.Text != parameter.Name)
continue;

if (SymbolEqualityComparer.Default.Equals(
if (!SymbolEqualityComparer.Default.Equals(
semanticModel.GetSymbolInfo(identifier, cancellationToken).Symbol, parameter))
{
continue;
}

if (!IsInsideNameof(identifier, semanticModel, cancellationToken))
return true;
}

return false;
}

private static bool IsInsideNameof(
IdentifierNameSyntax identifier,
SemanticModel semanticModel,
System.Threading.CancellationToken cancellationToken)
{
for (var operation = semanticModel.GetOperation(identifier, cancellationToken);
operation != null;
operation = operation.Parent)
{
if (operation is INameOfOperation)
return true;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,30 @@ protected override async Task ExecuteAsync(CancellationToken {|#0:stoppingToken|
await CreateTest(test, expected).RunAsync();
}

[Fact]
public async Task ExecuteAsync_OnlyNamesStoppingToken_ShouldReportDiagnostic()
{
var test = @"
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;

public class Worker : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken {|#0:stoppingToken|})
{
_ = nameof(stoppingToken);
await Task.Delay(1000);
}
}";

var expected = new DiagnosticResult("CC017", DiagnosticSeverity.Warning)
.WithLocation(0)
.WithArguments("stoppingToken");

await CreateTest(test, expected).RunAsync();
}

[Fact]
public async Task ExpressionBodiedExecuteAsync_IgnoresStoppingToken_ShouldReportDiagnostic()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,26 @@ public async Task RunAsync(CancellationToken {|#0:cancellationToken|})
await VerifyCS.VerifyAnalyzerAsync(test, expected);
}

[Fact]
public async Task AsyncMethod_TokenOnlyInNameof_ShouldReportDiagnostic()
{
var test = @"
using System.Threading;
using System.Threading.Tasks;

public class TestClass
{
public async Task RunAsync(CancellationToken {|#0:cancellationToken|})
{
_ = nameof(cancellationToken);
await Task.Delay(1000);
}
}";

var expected = VerifyCS.Diagnostic("CC016").WithLocation(0).WithArguments("cancellationToken");
await VerifyCS.VerifyAnalyzerAsync(test, expected);
}

[Fact]
public async Task AsyncIterator_EnumeratorCancellationToken_NotReferencedInBody_ShouldNotReportDiagnostic()
{
Expand Down
Loading