Skip to content

Commit 7874fe6

Browse files
Copilotstephentoub
authored andcommitted
Add CA2027 analyzer for non-cancelable Task.Delay in Task.WhenAny
1 parent 65384c6 commit 7874fe6

21 files changed

+707
-5
lines changed

src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2166,6 +2166,18 @@ JsonDocument implements IDisposable and needs to be properly disposed. When only
21662166
|CodeFix|True|
21672167
---
21682168

2169+
## [CA2027](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2027): Cancel Task.Delay after Task.WhenAny completes
2170+
2171+
When Task.Delay is used with Task.WhenAny to implement a timeout, the timer created by Task.Delay continues to run even after WhenAny completes, wasting resources. If your target framework supports Task.WaitAsync, use that instead as it has built-in timeout support without leaving timers running. Otherwise, pass a CancellationToken to Task.Delay that can be canceled when the operation completes.
2172+
2173+
|Item|Value|
2174+
|-|-|
2175+
|Category|Reliability|
2176+
|Enabled|True|
2177+
|Severity|Info|
2178+
|CodeFix|False|
2179+
---
2180+
21692181
## [CA2100](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2100): Review SQL queries for security vulnerabilities
21702182

21712183
SQL queries that directly use user input can be vulnerable to SQL injection attacks. Review this SQL query for potential vulnerabilities, and consider using a parameterized SQL query.

src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers.sarif

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3870,6 +3870,26 @@
38703870
]
38713871
}
38723872
},
3873+
"CA2027": {
3874+
"id": "CA2027",
3875+
"shortDescription": "Cancel Task.Delay after Task.WhenAny completes",
3876+
"fullDescription": "When Task.Delay is used with Task.WhenAny to implement a timeout, the timer created by Task.Delay continues to run even after WhenAny completes, wasting resources. If your target framework supports Task.WaitAsync, use that instead as it has built-in timeout support without leaving timers running. Otherwise, pass a CancellationToken to Task.Delay that can be canceled when the operation completes.",
3877+
"defaultLevel": "note",
3878+
"helpUri": "https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2027",
3879+
"properties": {
3880+
"category": "Reliability",
3881+
"isEnabledByDefault": true,
3882+
"typeName": "DoNotUseNonCancelableTaskDelayWithWhenAny",
3883+
"languages": [
3884+
"C#",
3885+
"Visual Basic"
3886+
],
3887+
"tags": [
3888+
"Telemetry",
3889+
"EnabledRuleInAggressiveMode"
3890+
]
3891+
}
3892+
},
38733893
"CA2100": {
38743894
"id": "CA2100",
38753895
"shortDescription": "Review SQL queries for security vulnerabilities",

src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/AnalyzerReleases.Unshipped.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@ CA2023 | Reliability | Warning | LoggerMessageDefineAnalyzer, [Documentation](ht
1111
CA2024 | Reliability | Warning | DoNotUseEndOfStreamInAsyncMethods, [Documentation](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2024)
1212
CA2025 | Reliability | Disabled | DoNotPassDisposablesIntoUnawaitedTasksAnalyzer, [Documentation](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2025)
1313
CA2026 | Reliability | Info | PreferJsonElementParse, [Documentation](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2026)
14+
CA2027 | Reliability | Info | DoNotUseNonCancelableTaskDelayWithWhenAny, [Documentation](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2027)

src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/MicrosoftNetCoreAnalyzersResources.resx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1634,6 +1634,15 @@
16341634
<data name="DoNotUseWhenAllWithSingleTaskFix" xml:space="preserve">
16351635
<value>Replace 'WhenAll' call with argument</value>
16361636
</data>
1637+
<data name="DoNotUseNonCancelableTaskDelayWithWhenAnyTitle" xml:space="preserve">
1638+
<value>Cancel Task.Delay after Task.WhenAny completes</value>
1639+
</data>
1640+
<data name="DoNotUseNonCancelableTaskDelayWithWhenAnyMessage" xml:space="preserve">
1641+
<value>Using Task.WhenAny with Task.Delay may result in a timer continuing to run after the operation completes, wasting resources</value>
1642+
</data>
1643+
<data name="DoNotUseNonCancelableTaskDelayWithWhenAnyDescription" xml:space="preserve">
1644+
<value>When Task.Delay is used with Task.WhenAny to implement a timeout, the timer created by Task.Delay continues to run even after WhenAny completes, wasting resources. If your target framework supports Task.WaitAsync, use that instead as it has built-in timeout support without leaving timers running. Otherwise, pass a CancellationToken to Task.Delay that can be canceled when the operation completes.</value>
1645+
</data>
16371646
<data name="UseStringEqualsOverStringCompareCodeFixTitle" xml:space="preserve">
16381647
<value>Use 'string.Equals'</value>
16391648
</data>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
// Copyright (c) Microsoft. All Rights Reserved. Licensed under the MIT license. See License.txt in the project root for license information.
2+
3+
using System.Collections.Immutable;
4+
using System.Linq;
5+
using Analyzer.Utilities;
6+
using Analyzer.Utilities.Extensions;
7+
using Analyzer.Utilities.Lightup;
8+
using Microsoft.CodeAnalysis;
9+
using Microsoft.CodeAnalysis.Diagnostics;
10+
using Microsoft.CodeAnalysis.Operations;
11+
12+
namespace Microsoft.NetCore.Analyzers.Tasks
13+
{
14+
using static MicrosoftNetCoreAnalyzersResources;
15+
16+
/// <summary>
17+
/// CA2027: <inheritdoc cref="DoNotUseNonCancelableTaskDelayWithWhenAnyTitle"/>
18+
/// </summary>
19+
[DiagnosticAnalyzer(LanguageNames.CSharp, LanguageNames.VisualBasic)]
20+
public sealed class DoNotUseNonCancelableTaskDelayWithWhenAny : DiagnosticAnalyzer
21+
{
22+
internal const string RuleId = "CA2027";
23+
24+
internal static readonly DiagnosticDescriptor Rule = DiagnosticDescriptorHelper.Create(
25+
RuleId,
26+
CreateLocalizableResourceString(nameof(DoNotUseNonCancelableTaskDelayWithWhenAnyTitle)),
27+
CreateLocalizableResourceString(nameof(DoNotUseNonCancelableTaskDelayWithWhenAnyMessage)),
28+
DiagnosticCategory.Reliability,
29+
RuleLevel.IdeSuggestion,
30+
CreateLocalizableResourceString(nameof(DoNotUseNonCancelableTaskDelayWithWhenAnyDescription)),
31+
isPortedFxCopRule: false,
32+
isDataflowRule: false);
33+
34+
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get; } = ImmutableArray.Create(Rule);
35+
36+
public override void Initialize(AnalysisContext context)
37+
{
38+
context.EnableConcurrentExecution();
39+
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
40+
41+
context.RegisterCompilationStartAction(context =>
42+
{
43+
var compilation = context.Compilation;
44+
45+
if (!compilation.TryGetOrCreateTypeByMetadataName(WellKnownTypeNames.SystemThreadingTasksTask, out var taskType) ||
46+
!compilation.TryGetOrCreateTypeByMetadataName(WellKnownTypeNames.SystemThreadingCancellationToken, out var cancellationTokenType))
47+
{
48+
return;
49+
}
50+
51+
context.RegisterOperationAction(context =>
52+
{
53+
var invocation = (IInvocationOperation)context.Operation;
54+
55+
// Check if this is a call to Task.WhenAny
56+
if (!IsTaskWhenAny(invocation.TargetMethod, taskType))
57+
{
58+
return;
59+
}
60+
61+
// Count the total number of tasks passed to WhenAny
62+
int taskCount = 0;
63+
System.Collections.Generic.List<IOperation>? taskDelayOperations = null;
64+
65+
// Task.WhenAny has params parameters, so arguments are often implicitly wrapped in an array
66+
// We need to check inside the array initializer or collection expression
67+
foreach (var argument in invocation.Arguments)
68+
{
69+
// Check if this is an array creation (implicit params expansion, explicit array, or collection expression)
70+
if (argument.Value is IArrayCreationOperation { Initializer: not null } arrayCreation)
71+
{
72+
// Check each element in the array
73+
foreach (var element in arrayCreation.Initializer.ElementValues)
74+
{
75+
taskCount++;
76+
if (IsNonCancelableTaskDelay(element, taskType, cancellationTokenType))
77+
{
78+
taskDelayOperations ??= new System.Collections.Generic.List<IOperation>();
79+
taskDelayOperations.Add(element);
80+
}
81+
}
82+
}
83+
else if (ICollectionExpressionOperationWrapper.IsInstance(argument.Value))
84+
{
85+
// Check each element in the collection expression
86+
var collectionExpression = ICollectionExpressionOperationWrapper.FromOperation(argument.Value);
87+
foreach (var element in collectionExpression.Elements)
88+
{
89+
taskCount++;
90+
if (IsNonCancelableTaskDelay(element, taskType, cancellationTokenType))
91+
{
92+
taskDelayOperations ??= new System.Collections.Generic.List<IOperation>();
93+
taskDelayOperations.Add(element);
94+
}
95+
}
96+
}
97+
else
98+
{
99+
// Direct argument (not params or array)
100+
taskCount++;
101+
if (IsNonCancelableTaskDelay(argument.Value, taskType, cancellationTokenType))
102+
{
103+
taskDelayOperations ??= new System.Collections.Generic.List<IOperation>();
104+
taskDelayOperations.Add(argument.Value);
105+
}
106+
}
107+
}
108+
109+
// Only report diagnostics if there are at least 2 tasks total
110+
// (avoid flagging Task.WhenAny(Task.Delay(...)) which may be used to avoid exceptions)
111+
if (taskCount >= 2 && taskDelayOperations is not null)
112+
{
113+
foreach (var operation in taskDelayOperations)
114+
{
115+
context.ReportDiagnostic(operation.CreateDiagnostic(Rule));
116+
}
117+
}
118+
}, OperationKind.Invocation);
119+
});
120+
}
121+
122+
private static bool IsTaskWhenAny(IMethodSymbol method, INamedTypeSymbol taskType)
123+
{
124+
return SymbolEqualityComparer.Default.Equals(method.ContainingType, taskType) &&
125+
method.IsStatic &&
126+
method.Name == nameof(System.Threading.Tasks.Task.WhenAny);
127+
}
128+
129+
private static bool IsNonCancelableTaskDelay(IOperation operation, INamedTypeSymbol taskType, INamedTypeSymbol cancellationTokenType)
130+
{
131+
// Unwrap conversions to get to the actual invocation
132+
if (operation is IConversionOperation conversion)
133+
{
134+
operation = conversion.Operand;
135+
}
136+
137+
if (operation is not IInvocationOperation invocation)
138+
{
139+
return false;
140+
}
141+
142+
var method = invocation.TargetMethod;
143+
144+
// Check if this is Task.Delay
145+
if (!SymbolEqualityComparer.Default.Equals(method.ContainingType, taskType) ||
146+
!method.IsStatic ||
147+
method.Name != nameof(System.Threading.Tasks.Task.Delay))
148+
{
149+
return false;
150+
}
151+
152+
// Check if any parameter is a CancellationToken
153+
foreach (var parameter in method.Parameters)
154+
{
155+
if (SymbolEqualityComparer.Default.Equals(parameter.Type, cancellationTokenType))
156+
{
157+
return false; // Has a CancellationToken parameter, so it's cancelable
158+
}
159+
}
160+
161+
return true; // Task.Delay without CancellationToken
162+
}
163+
}
164+
}

src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/xlf/MicrosoftNetCoreAnalyzersResources.cs.xlf

Lines changed: 15 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/xlf/MicrosoftNetCoreAnalyzersResources.de.xlf

Lines changed: 15 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/xlf/MicrosoftNetCoreAnalyzersResources.es.xlf

Lines changed: 15 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)