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.181] - 2026-07-21

### Fixed

- **CC020 / CC021** false negatives: mentioning `ServerCallContext.CancellationToken` or
`HttpContext.RequestAborted` only inside `nameof(...)` no longer counts as observing request
cancellation. These compile-time-only references now leave the runtime token-use diagnostic active.

## [1.27.180] - 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.180 hardening loop)
Reviewed: 2026-07-21 (refreshed through the v1.27.181 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 @@ -47,8 +47,8 @@ Calibration notes:
| CC024 | `async` lambda converted to `Action` | Usage | Warning | 4 | 4 | n/a | 4 | 3 | 4 | Low | **v1.20.0 (new):** the lambda counterpart of CC023. Flags an `async` lambda whose converted delegate type is `System.Action`/`Action<…>` (binds as async void). Catches the `Parallel.ForEach(..., async x => …)` trap. `Func<Task>` and event-handler delegates are not `Action`, so excluded. Analyzer-only (the right delegate depends on the consuming API). |
| CC023 | `async void` (non-event-handler) | Usage | Warning | 4 | 4 | 4 | 4 | 3 | 4 | Low | **v1.19.0 (new):** flags an `async void` method that is not an event handler (`(object, EventArgs)` shape, EventArgs subclasses included) and not externally-controlled; fixer changes `void`→`Task` + adds the Tasks import. Classic async anti-pattern (cf. VSTHRD100) — `async void` can't be awaited or cancelled and crashes on unhandled exceptions. |
| CC022 | Prefer `CancelAsync()` over `Cancel()` in async | Usage | Info | 4 | 4 | 4 | 4 | 3 | 3 | Low | **v1.18.0 (new):** flags a parameterless `CancellationTokenSource.Cancel()` inside async code; fixer rewrites to `await cts.CancelAsync()`. Info (`Cancel()` is still valid). The `Cancel(bool)` overload and sync contexts are excluded. Modern .NET 8 guidance — `Cancel()` runs callbacks synchronously on the caller. |
| CC021 | `HttpContext.RequestAborted` not observed | Usage | Info | 4 | 3 | n/a | 4 | 3 | 3 | Low | **v1.16.0 (new):** the HttpContext parallel of CC020. Flags a method with a `Microsoft.AspNetCore.Http.HttpContext` parameter that does async work but never reads `context.RequestAborted` and never passes the context on. Info because HttpContext is often taken for non-cancellation reasons (hence FP score 3). Shares `AccessesMember`/`ParameterEscapesAsArgument` with CC020. |
| 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). |
| CC021 | `HttpContext.RequestAborted` not observed | Usage | Info | 4 | 3 | n/a | 4 | 3 | 3 | Low | **v1.16.0 (new):** the HttpContext parallel of CC020. Flags a method with a `Microsoft.AspNetCore.Http.HttpContext` parameter that does async work but never reads `context.RequestAborted` and never passes the context on. **v1.27.181:** a compile-time-only `nameof(context.RequestAborted)` no longer counts as observation. Info because HttpContext is often taken for non-cancellation reasons (hence FP score 3). Shares `AccessesMember`/`ParameterEscapesAsArgument` with CC020. |
| 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. **v1.27.181:** a compile-time-only `nameof(context.CancellationToken)` no longer counts as observation. 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); 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. |
Expand Down Expand Up @@ -168,6 +168,9 @@ Grading: **P0** = release-blocking; **P1** = next hardening loop; **P2** = oppor

## Verification Baseline

- v1.27.181: 638 tests, green locally. **CC020/CC021 FN fix:** compile-time-only
`nameof(context.CancellationToken)` and `nameof(context.RequestAborted)` references no longer
suppress the runtime token-observation diagnostics.
- v1.27.180: 636 tests, green locally. **CC009 FN fix:** a loop that mentions
`token.IsCancellationRequested` only inside compile-time `nameof(...)` is now diagnosed because it
still performs no runtime cancellation check.
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.180</Version>
<Version>1.27.181</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
10 changes: 7 additions & 3 deletions src/CancelCop.Analyzer/CancellationTokenHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -349,8 +349,9 @@ public static bool IsInsideNameof(

/// <summary>
/// Returns true when <paramref name="body"/> reads a member named <paramref name="memberName"/>
/// off <paramref name="parameter"/> (e.g. <c>context.CancellationToken</c>). Used by the
/// context-token rules (CC020/CC021) to tell an observed token from an ignored one.
/// off <paramref name="parameter"/> at runtime (e.g. <c>context.CancellationToken</c>).
/// Compile-time-only <c>nameof</c> references are ignored. Used by the context-token rules
/// (CC020/CC021) to tell an observed token from an ignored one.
/// </summary>
public static bool AccessesMember(
SyntaxNode body,
Expand All @@ -365,8 +366,11 @@ public static bool AccessesMember(
continue;

if (SymbolEqualityComparer.Default.Equals(
semanticModel.GetSymbolInfo(memberAccess.Expression, cancellationToken).Symbol, parameter))
semanticModel.GetSymbolInfo(memberAccess.Expression, cancellationToken).Symbol, parameter) &&
!IsInsideNameof(memberAccess, semanticModel, cancellationToken))
{
return true;
}
}

return false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,27 @@ public async Task<string> SayHello(string request, ServerCallContext context)
await VerifyCS.VerifyAnalyzerAsync(test);
}

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

public class GreeterService
{
public async Task<string> SayHello(string request, ServerCallContext {|#0:context|})
{
_ = nameof(context.CancellationToken);
await Task.Yield();
return ""hi"";
}
}" + ContextStub;

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

[Fact]
public async Task GrpcMethod_PassesContextOn_ShouldNotReportDiagnostic()
{
Expand Down
20 changes: 20 additions & 0 deletions tests/CancelCop.Analyzer.Tests/RequestAbortedAnalyzerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,26 @@ public async Task InvokeAsync(HttpContext context)
await VerifyCS.VerifyAnalyzerAsync(test);
}

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

public class Middleware
{
public async Task InvokeAsync(HttpContext {|#0:context|})
{
_ = nameof(context.RequestAborted);
await Task.Yield();
}
}" + ContextStub;

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

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