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

### Fixed

- **CC027** false negative: returning an async call through an interface or base-type cast of a
`using`-scoped resource is now diagnosed. The cast does not extend the resource lifetime, so it
is still disposed before the returned task completes.

## [1.27.183] - 2026-07-21

### Fixed
Expand Down
7 changes: 5 additions & 2 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.183 hardening loop)
Reviewed: 2026-07-21 (refreshed through the v1.27.184 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 @@ -41,7 +41,7 @@ Calibration notes:
| CC009 | Loop missing cancellation check | Usage | Warning | 4 | 4 | 4 | 4 | 4 | 4 | Low | v1.4.0: semantic receiver resolution (no name matching), walks methods/local functions/lambdas, all four loop kinds, fixer inserts `ThrowIfCancellationRequested()`. **v1.27.180:** a compile-time-only `nameof(token.IsCancellationRequested)` reference no longer counts as a runtime cancellation check. The strongest rule in the set. |
| CC010 | `await foreach` missing CancellationToken flow | Usage | Warning | 4 | 4 | 4 | 4 | 3 | 4 | Low | **v1.5.0 (new):** flags `await foreach` over an `IAsyncEnumerable<T>` (or implementer) when a token is in scope, the source does not already pass a token argument, and it is not already a configured cancelable enumerable; fixer rewrites the source to `.WithCancellation(token)`. **v1.27.183:** `WithCancellation` wrapper recognition is semantic and framework-gated, so a custom zero-argument look-alike no longer suppresses the rule. Uses the shared `FindEnclosingCancellationTokenParameter` scope walk. Conservative: synchronous `foreach`, no-token scopes, and producer calls already receiving a token are quiet. No analyzer XML `<remarks>` example variety yet (P3). |
| CC028 | Blocking `System.IO` read/write/append in async code | Usage | Warning | 4 | 4 | 4 | 4 | 4 | 4 | Low | **v1.24.0 (new); fix v1.25.0:** flags a blocking synchronous `System.IO` helper inside async code (method/local function/lambda/anonymous method) when a signature-compatible `<name>Async` counterpart exists on the type — `File` read/write/append (`ReadAllText`/`ReadAllBytes`/`ReadAllLines`, `WriteAll*`, `AppendAll*`), `StreamReader.ReadToEnd`/`ReadLine`, and (v1.27.0) `StreamWriter.Write`/`WriteLine`/`Flush` (generalised from `System.IO.File` to `System.IO` in v1.26.0). **v1.27.0** replaced the name-only counterpart lookup with a parameter-signature match (overload equals the call's params, optionally + a trailing token), so the rewrite always compiles (`StreamWriter.Write(bool)` has no async form → quiet) and the token is only flowed when the matched overload accepts one (`Write(string)`→`await WriteAsync(text)` tokenless; `Flush()`→`await FlushAsync(token)`). Fixer rewrites to `await …Async(…[, token])`, flowing the in-scope token via `FindEnclosingCancellationTokenParameter` (named arg when the call uses named args, await parenthesized when the result is a receiver/element/conditional access). Symbol-resolved + namespace-gated to `System.IO` (look-alike `File`/`StreamReader` ignored); only in async context via the shared `IsInAsyncFunction`. Fix-All batches across the type→method map. Rounds out the blocking-in-async family (CC013/CC015/CC026). |
| CC027 | Returned task uses a disposed `using` resource | Usage | Warning | 4 | 4 | n/a | 4 | 3 | 4 | Low | **v1.23.0 (new):** flags a non-async `Task`-returning method/local function whose `return` is a call on a `using`-declared local — the resource is disposed before the returned task completes (premature disposal). High confidence: only the receiver case is flagged (a synchronous read into a completed task like `Task.FromResult(resource.Value)` is not). Analyzer-only (fix = make async + await). |
| CC027 | Returned task uses a disposed `using` resource | Usage | Warning | 4 | 4 | n/a | 4 | 3 | 4 | Low | **v1.23.0 (new):** flags a non-async `Task`-returning method/local function whose `return` is a call on a `using`-declared local — the resource is disposed before the returned task completes (premature disposal). **v1.27.184:** receiver walking unwraps interface/base casts, which do not change the using local's lifetime. High confidence: only the receiver case is flagged (a synchronous read into a completed task like `Task.FromResult(resource.Value)` is not). Analyzer-only (fix = make async + await). |
| CC026 | `SemaphoreSlim.Wait()` in async code | Usage | Warning | 4 | 4 | 4 | 4 | 3 | 4 | Low | **v1.22.0 (new):** flags every `SemaphoreSlim.Wait()` overload (parameterless, timeout, token) inside async code — a classic deadlock source; fixer → `await gate.WaitAsync(…)`, carrying the original arguments through and injecting the in-scope token when `Wait()` was parameterless (v1.22.2). Symbol-resolved to `System.Threading.SemaphoreSlim`. |
| CC025 | Prefer `await using` for `IAsyncDisposable` | Usage | Info | 4 | 4 | 4 | 4 | 3 | 3 | Low | **v1.21.0 (new):** flags a `using` statement/declaration (no `await`) over an `IAsyncDisposable` resource in async code; fixer prepends `await`. Both the declaration (`using var x = …`) and statement (`using (…)`) forms, expression and variable receivers. Info. |
| 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). |
Expand Down Expand Up @@ -168,6 +168,9 @@ Grading: **P0** = release-blocking; **P1** = next hardening loop; **P2** = oppor

## Verification Baseline

- v1.27.184: 641 tests, green locally. **CC027 FN fix:** a returned async call through an
interface/base cast of the using-scoped receiver is now diagnosed because the same local is still
disposed before task completion.
- v1.27.183: 640 tests, green locally. **CC010 FN fix:** a custom method merely named
`WithCancellation` no longer counts as configured token flow; only the framework API is unwrapped
as a cancellation-aware enumerable.
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.183</Version>
<Version>1.27.184</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
12 changes: 8 additions & 4 deletions src/CancelCop.Analyzer/ReturnedTaskUsingDisposedAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,9 @@ namespace CancelCop.Analyzer;
/// <para>
/// <b>What it detects:</b> a non-<c>async</c> method or local function returning
/// <c>Task</c>/<c>Task&lt;T&gt;</c>/<c>ValueTask</c> where a <c>return</c> expression is a call whose
/// left-most receiver is a <c>using</c>-declared local. Only the receiver case is flagged (high
/// confidence); a resource read synchronously into a completed task — e.g.
/// left-most receiver is a <c>using</c>-declared local, including when the receiver is cast to a
/// base type or interface. Only the receiver case is flagged (high confidence); a resource read
/// synchronously into a completed task — e.g.
/// <c>Task.FromResult(resource.Value)</c> — is not.
/// </para>
/// </remarks>
Expand Down Expand Up @@ -147,8 +148,8 @@ private static IEnumerable<SyntaxNode> DescendantsInOwnScope(SyntaxNode body) =>
child is not LocalFunctionStatementSyntax && child is not AnonymousFunctionExpressionSyntax);

/// <summary>
/// Walks an invocation/member-access (and conditional-access/parenthesis) chain down to its
/// left-most receiver expression.
/// Walks an invocation/member-access (and conditional-access/parenthesis/cast) chain down to
/// its left-most receiver expression.
/// </summary>
private static ExpressionSyntax GetLeftmostReceiver(ExpressionSyntax expression)
{
Expand All @@ -168,6 +169,9 @@ private static ExpressionSyntax GetLeftmostReceiver(ExpressionSyntax expression)
case ParenthesizedExpressionSyntax parenthesized:
expression = parenthesized.Expression;
break;
case CastExpressionSyntax cast:
expression = cast.Expression;
Comment on lines +172 to +173

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Do not unwrap user-defined cast conversions

When a using-declared type has a user-defined conversion to another object, unconditionally unwrapping the cast reports CC027 even though the converted object—not the disposable local—is the invocation receiver. For example, return ((Worker)resource).DoAsync(); is safe when an explicit conversion creates an independent Worker, but this walk resolves resource and emits a false positive. Only unwrap identity/reference casts such as the intended base/interface conversions, and stop when the conversion invokes a user-defined operator.

Useful? React with 👍 / 👎.

break;
default:
return expression;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,37 @@ public Task<int> ReadAsync()
await VerifyCS.VerifyAnalyzerAsync(test, expected);
}

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

public interface IResource : IDisposable
{
Task<int> DoAsync();
}

public sealed class Resource : IResource
{
public void Dispose() { }
public Task<int> DoAsync() => Task.FromResult(0);
}

public class TestClass
{
public Task<int> ReadAsync()
{
using var resource = new Resource();
return {|#0:((IResource)resource).DoAsync()|};
}
}";

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

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