From 8884cb99558edaaecac2cd6eb7f24f43d0f5f42e Mon Sep 17 00:00:00 2001 From: George Wall <84231788+georgepwall1991@users.noreply.github.com> Date: Tue, 21 Jul 2026 23:41:25 +0100 Subject: [PATCH] =?UTF-8?q?fix:=20diagnose=20conditional=20Task=20result?= =?UTF-8?q?=20access=20=E2=80=94=20closes=20a=20blocking=20false=20negativ?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 8 ++++++++ docs/ANALYZER_HEALTH.md | 6 ++++-- .../CancelCop.Analyzer.Package.csproj | 2 +- src/CancelCop.Analyzer/BlockingOnAsyncAnalyzer.cs | 15 +++++++++++++++ .../BlockingOnAsyncAnalyzerTests.cs | 15 +++++++++++++++ 5 files changed, 43 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3649d19..b3a8ff0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.27.194] - 2026-07-21 + +### Fixed + +- **CC015** false negative: null-conditional `Task`/`ValueTask` result access + (`task?.Result`) is now diagnosed in async code. No automatic fix is offered because a + null-preserving await rewrite is context-dependent. + ## [1.27.193] - 2026-07-21 ### Fixed diff --git a/docs/ANALYZER_HEALTH.md b/docs/ANALYZER_HEALTH.md index 83c0750..4c19de8 100644 --- a/docs/ANALYZER_HEALTH.md +++ b/docs/ANALYZER_HEALTH.md @@ -1,6 +1,6 @@ # Analyzer Health -Reviewed: 2026-07-21 (refreshed through the v1.27.193 hardening loop) +Reviewed: 2026-07-21 (refreshed through the v1.27.194 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 @@ -53,7 +53,7 @@ Calibration notes: | 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` 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. | | 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/v1.27.178:** flags a method/local function that does async work (has `await` in its current function scope) but never references its `CancellationToken` parameter at runtime; excludes externally-controlled signatures and sync bodies. Await eligibility stops at nested lambdas/local functions, while token-reference analysis still descends into them because captures are real usage. A compile-time-only `nameof(token)` does not count. A token marked `[EnumeratorCancellation]` is excluded because the async-iterator infrastructure observes it (cf. CC011). Analyzer-only by design. | -| CC015 | Blocking on async code (sync-over-async) | Usage | Warning | 4 | 4 | 4 | 4 | 3 | 4 | Low | **v1.10.0 (new); fixes v1.27.188–v1.27.190/v1.27.192:** flags `.Result`/potentially blocking `.Wait(...)`/`.GetAwaiter().GetResult()` on a `Task`/`Task`/`ValueTask` inside an `async` function; fixer rewrites safe shapes to `await`. Provably zero integer and exact framework `TimeSpan` timeout forms (zero field, defaults, zero-argument construction) are excluded because they are immediate completion probes; this applies to instance `Wait` and static `WaitAll`/`WaitAny`. Symbol-resolved (lookalikes ignored). Shares `IsInAsyncFunction` with CC013. | +| CC015 | Blocking on async code (sync-over-async) | Usage | Warning | 4 | 4 | 4 | 4 | 3 | 4 | Low | **v1.10.0 (new); fixes v1.27.188–v1.27.190/v1.27.192/v1.27.194:** flags `.Result` (including null-conditional access), potentially blocking `.Wait(...)`, and `.GetAwaiter().GetResult()` on a `Task`/`Task`/`ValueTask` inside an `async` function; fixer rewrites safe direct-access shapes to `await`. Provably zero integer and exact framework `TimeSpan` timeout forms (zero field, defaults, zero-argument construction) are excluded because they are immediate completion probes; this applies to instance `Wait` and static `WaitAll`/`WaitAny`. Symbol-resolved (lookalikes ignored). Shares `IsInAsyncFunction` with CC013. | | CC014 | `CancellationTokenSource` never disposed | Usage | Warning | 4 | 4 | 4 | 4 | 3 | 4 | Low | **v1.9.0 (new); fix v1.27.179:** 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. Top-level-program locals use the compilation unit as their synthesized function boundary and receive the same safe `using var` fix. 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). | | CC012 | Explicit `CancellationToken.None`/`default` when a token is in scope | Usage | Info | 4 | 4 | 4 | 4 | 3 | 3 | Low | **v1.7.0 (new); fix v1.27.176:** flags the actual `System.Threading.CancellationToken.None` property or `default`/`default(CancellationToken)` bound to a `CancellationToken` parameter when an in-scope token exists; fixer swaps in the token. The framework property is symbol-resolved, so a custom token-valued property merely named `None` stays quiet. Info severity because best-effort cleanup legitimately opts out. Uses the shared scope walk + converted-type gate (a bare `default` only counts in token context). | @@ -168,6 +168,8 @@ Grading: **P0** = release-blocking; **P1** = next hardening loop; **P2** = oppor ## Verification Baseline +- v1.27.194: 655 tests, green locally. **CC015 FN fix:** null-conditional task-like + `.Result` access is diagnosed; context-dependent null-preserving fixes remain intentionally absent. - v1.27.193: 654 tests, green locally. **CC026 FP fix:** zero-argument `TimeSpan` construction and named target-typed `new()` are excluded as immediate semaphore probes. - v1.27.192: 653 tests, green locally. **CC015 FP fix:** zero-argument framework `TimeSpan` diff --git a/src/CancelCop.Analyzer.Package/CancelCop.Analyzer.Package.csproj b/src/CancelCop.Analyzer.Package/CancelCop.Analyzer.Package.csproj index 2f991fa..7735613 100644 --- a/src/CancelCop.Analyzer.Package/CancelCop.Analyzer.Package.csproj +++ b/src/CancelCop.Analyzer.Package/CancelCop.Analyzer.Package.csproj @@ -13,7 +13,7 @@ CancelCop.Analyzer - 1.27.193 + 1.27.194 George Wall 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. roslyn;analyzer;cancellationtoken;async;code-fix;loops;efcore;httpclient diff --git a/src/CancelCop.Analyzer/BlockingOnAsyncAnalyzer.cs b/src/CancelCop.Analyzer/BlockingOnAsyncAnalyzer.cs index ec9e25a..03995a0 100644 --- a/src/CancelCop.Analyzer/BlockingOnAsyncAnalyzer.cs +++ b/src/CancelCop.Analyzer/BlockingOnAsyncAnalyzer.cs @@ -69,6 +69,7 @@ public override void Initialize(AnalysisContext context) context.EnableConcurrentExecution(); context.RegisterSyntaxNodeAction(AnalyzeMemberAccess, SyntaxKind.SimpleMemberAccessExpression); + context.RegisterSyntaxNodeAction(AnalyzeMemberBinding, SyntaxKind.MemberBindingExpression); context.RegisterSyntaxNodeAction(AnalyzeInvocation, SyntaxKind.InvocationExpression); } @@ -87,6 +88,20 @@ private void AnalyzeMemberAccess(SyntaxNodeAnalysisContext context) Report(context, memberAccess.Name, ".Result"); } + private void AnalyzeMemberBinding(SyntaxNodeAnalysisContext context) + { + var memberBinding = (MemberBindingExpressionSyntax)context.Node; + if (memberBinding.Name.Identifier.Text != "Result") + return; + + if (context.SemanticModel.GetSymbolInfo(memberBinding, context.CancellationToken).Symbol is not IPropertySymbol property) + return; + if (!IsTaskLike(property.ContainingType)) + return; + + Report(context, memberBinding.Name, ".Result"); + } + private void AnalyzeInvocation(SyntaxNodeAnalysisContext context) { var invocation = (InvocationExpressionSyntax)context.Node; diff --git a/tests/CancelCop.Analyzer.Tests/BlockingOnAsyncAnalyzerTests.cs b/tests/CancelCop.Analyzer.Tests/BlockingOnAsyncAnalyzerTests.cs index 93b5cd8..31f5ec4 100644 --- a/tests/CancelCop.Analyzer.Tests/BlockingOnAsyncAnalyzerTests.cs +++ b/tests/CancelCop.Analyzer.Tests/BlockingOnAsyncAnalyzerTests.cs @@ -79,6 +79,21 @@ public async Task RunAsync() await VerifyCS.VerifyAnalyzerAsync(test, expected); } + [Fact] + public async Task ConditionalResult_InAsyncMethod_ShouldReportDiagnostic() + { + var test = Harness + @" + public async Task RunAsync(Task task) + { + await Task.Yield(); + return task?.{|#0:Result|}; + } +}"; + + var expected = VerifyCS.Diagnostic("CC015").WithLocation(0).WithArguments(".Result"); + await VerifyCS.VerifyAnalyzerAsync(test, expected); + } + [Fact] public async Task Wait_InAsyncMethod_ShouldReportDiagnostic() {