Skip to content

Commit 2adaf93

Browse files
authored
Add CA2027: Detect non-cancelable Task.Delay in Task.WhenAny (#51452)
Co-authored-by: Copilot <[email protected]>
1 parent 448facf commit 2adaf93

20 files changed

+752
-1
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
@@ -2178,6 +2178,18 @@ JsonDocument implements IDisposable and needs to be properly disposed. When only
21782178
|CodeFix|True|
21792179
---
21802180

2181+
## [CA2027](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2027): Cancel Task.Delay after Task.WhenAny completes
2182+
2183+
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.
2184+
2185+
|Item|Value|
2186+
|-|-|
2187+
|Category|Reliability|
2188+
|Enabled|True|
2189+
|Severity|Info|
2190+
|CodeFix|False|
2191+
---
2192+
21812193
## [CA2100](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2100): Review SQL queries for security vulnerabilities
21822194

21832195
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
@@ -3890,6 +3890,26 @@
38903890
]
38913891
}
38923892
},
3893+
"CA2027": {
3894+
"id": "CA2027",
3895+
"shortDescription": "Cancel Task.Delay after Task.WhenAny completes",
3896+
"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.",
3897+
"defaultLevel": "note",
3898+
"helpUri": "https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2027",
3899+
"properties": {
3900+
"category": "Reliability",
3901+
"isEnabledByDefault": true,
3902+
"typeName": "DoNotUseNonCancelableTaskDelayWithWhenAny",
3903+
"languages": [
3904+
"C#",
3905+
"Visual Basic"
3906+
],
3907+
"tags": [
3908+
"Telemetry",
3909+
"EnabledRuleInAggressiveMode"
3910+
]
3911+
}
3912+
},
38933913
"CA2100": {
38943914
"id": "CA2100",
38953915
"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
@@ -12,3 +12,4 @@ CA2023 | Reliability | Warning | LoggerMessageDefineAnalyzer, [Documentation](ht
1212
CA2024 | Reliability | Warning | DoNotUseEndOfStreamInAsyncMethods, [Documentation](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2024)
1313
CA2025 | Reliability | Disabled | DoNotPassDisposablesIntoUnawaitedTasksAnalyzer, [Documentation](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2025)
1414
CA2026 | Reliability | Info | PreferJsonElementParse, [Documentation](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2026)
15+
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
@@ -1646,6 +1646,15 @@
16461646
<data name="DoNotUseWhenAllWithSingleTaskFix" xml:space="preserve">
16471647
<value>Replace 'WhenAll' call with argument</value>
16481648
</data>
1649+
<data name="DoNotUseNonCancelableTaskDelayWithWhenAnyTitle" xml:space="preserve">
1650+
<value>Cancel Task.Delay after Task.WhenAny completes</value>
1651+
</data>
1652+
<data name="DoNotUseNonCancelableTaskDelayWithWhenAnyMessage" xml:space="preserve">
1653+
<value>Using Task.WhenAny with Task.Delay may result in a timer continuing to run after the operation completes, wasting resources</value>
1654+
</data>
1655+
<data name="DoNotUseNonCancelableTaskDelayWithWhenAnyDescription" xml:space="preserve">
1656+
<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>
1657+
</data>
16491658
<data name="UseStringEqualsOverStringCompareCodeFixTitle" xml:space="preserve">
16501659
<value>Use 'string.Equals'</value>
16511660
</data>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
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 Analyzer.Utilities;
5+
using Analyzer.Utilities.Extensions;
6+
using Analyzer.Utilities.Lightup;
7+
using Microsoft.CodeAnalysis;
8+
using Microsoft.CodeAnalysis.Diagnostics;
9+
using Microsoft.CodeAnalysis.Operations;
10+
using static Microsoft.NetCore.Analyzers.MicrosoftNetCoreAnalyzersResources;
11+
12+
namespace Microsoft.NetCore.Analyzers.Tasks
13+
{
14+
/// <summary>
15+
/// CA2027: <inheritdoc cref="DoNotUseNonCancelableTaskDelayWithWhenAnyTitle"/>
16+
/// </summary>
17+
[DiagnosticAnalyzer(LanguageNames.CSharp, LanguageNames.VisualBasic)]
18+
public sealed class DoNotUseNonCancelableTaskDelayWithWhenAny : DiagnosticAnalyzer
19+
{
20+
internal const string RuleId = "CA2027";
21+
22+
internal static readonly DiagnosticDescriptor Rule = DiagnosticDescriptorHelper.Create(
23+
RuleId,
24+
CreateLocalizableResourceString(nameof(DoNotUseNonCancelableTaskDelayWithWhenAnyTitle)),
25+
CreateLocalizableResourceString(nameof(DoNotUseNonCancelableTaskDelayWithWhenAnyMessage)),
26+
DiagnosticCategory.Reliability,
27+
RuleLevel.IdeSuggestion,
28+
CreateLocalizableResourceString(nameof(DoNotUseNonCancelableTaskDelayWithWhenAnyDescription)),
29+
isPortedFxCopRule: false,
30+
isDataflowRule: false);
31+
32+
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get; } = ImmutableArray.Create(Rule);
33+
34+
public override void Initialize(AnalysisContext context)
35+
{
36+
context.EnableConcurrentExecution();
37+
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
38+
39+
context.RegisterCompilationStartAction(context =>
40+
{
41+
var compilation = context.Compilation;
42+
43+
if (!compilation.TryGetOrCreateTypeByMetadataName(WellKnownTypeNames.SystemThreadingTasksTask, out var taskType) ||
44+
!compilation.TryGetOrCreateTypeByMetadataName(WellKnownTypeNames.SystemThreadingCancellationToken, out var cancellationTokenType))
45+
{
46+
return;
47+
}
48+
49+
context.RegisterOperationAction(context =>
50+
{
51+
var invocation = (IInvocationOperation)context.Operation;
52+
53+
// Check if this is a call to Task.WhenAny
54+
var method = invocation.TargetMethod;
55+
if (!SymbolEqualityComparer.Default.Equals(method.ContainingType, taskType) ||
56+
!method.IsStatic ||
57+
method.Name != nameof(Task.WhenAny))
58+
{
59+
return;
60+
}
61+
62+
// Count the total number of tasks passed to WhenAny
63+
int taskCount = 0;
64+
List<IOperation>? taskDelayOperations = null;
65+
66+
// Task.WhenAny has params parameters, so arguments are often implicitly wrapped in an array
67+
// We need to check inside the array initializer or collection expression
68+
for (int i = 0; i < invocation.Arguments.Length; i++)
69+
{
70+
var argument = invocation.Arguments[i].Value.WalkDownConversion();
71+
72+
// Check if this is an array creation
73+
if (argument is IArrayCreationOperation { Initializer: not null } arrayCreation)
74+
{
75+
// Check each element in the array
76+
foreach (var element in arrayCreation.Initializer.ElementValues)
77+
{
78+
taskCount++;
79+
if (IsNonCancelableTaskDelay(element, taskType, cancellationTokenType))
80+
{
81+
(taskDelayOperations ??= []).Add(element);
82+
}
83+
}
84+
}
85+
else if (ICollectionExpressionOperationWrapper.IsInstance(argument))
86+
{
87+
// Check each element in the collection expression
88+
var collectionExpression = ICollectionExpressionOperationWrapper.FromOperation(argument);
89+
foreach (var element in collectionExpression.Elements)
90+
{
91+
taskCount++;
92+
if (IsNonCancelableTaskDelay(element, taskType, cancellationTokenType))
93+
{
94+
(taskDelayOperations ??= []).Add(element);
95+
}
96+
}
97+
}
98+
else
99+
{
100+
// Direct argument (not params or array)
101+
taskCount++;
102+
if (IsNonCancelableTaskDelay(argument, taskType, cancellationTokenType))
103+
{
104+
(taskDelayOperations ??= []).Add(argument);
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 IsNonCancelableTaskDelay(IOperation operation, INamedTypeSymbol taskType, INamedTypeSymbol cancellationTokenType)
123+
{
124+
operation = operation.WalkDownConversion();
125+
126+
if (operation is not IInvocationOperation invocation)
127+
{
128+
return false;
129+
}
130+
131+
// Check if this is Task.Delay
132+
var method = invocation.TargetMethod;
133+
if (!SymbolEqualityComparer.Default.Equals(method.ContainingType, taskType) ||
134+
!method.IsStatic ||
135+
method.Name != nameof(Task.Delay))
136+
{
137+
return false;
138+
}
139+
140+
// Check if any parameter is a CancellationToken, in which case we consider it cancelable
141+
foreach (var parameter in method.Parameters)
142+
{
143+
if (SymbolEqualityComparer.Default.Equals(parameter.Type, cancellationTokenType))
144+
{
145+
return false;
146+
}
147+
}
148+
149+
return true; // Task.Delay without CancellationToken
150+
}
151+
}
152+
}

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)