Skip to content

Commit dc7bba0

Browse files
Copilotstephentoub
authored andcommitted
Add CA2027 analyzer for non-cancelable Task.Delay in Task.WhenAny
1 parent ba0db14 commit dc7bba0

23 files changed

+1335
-2
lines changed

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1908,6 +1908,18 @@ In many situations, logging is disabled or set to a log level that results in an
19081908
|CodeFix|True|
19091909
---
19101910

1911+
## [CA1876](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1876): Do not use 'AsParallel' in 'foreach'
1912+
1913+
Using 'AsParallel()' directly in a 'foreach' loop has no effect. The 'foreach' statement iterates serially through the collection regardless. To parallelize LINQ operations, call 'AsParallel()' earlier in the query chain before other LINQ operators. To parallelize the loop itself, use 'Parallel.ForEach' instead.
1914+
1915+
|Item|Value|
1916+
|-|-|
1917+
|Category|Performance|
1918+
|Enabled|True|
1919+
|Severity|Info|
1920+
|CodeFix|False|
1921+
---
1922+
19111923
## [CA2000](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2000): Dispose objects before losing scope
19121924

19131925
If a disposable object is not explicitly disposed before all references to it are out of scope, the object will be disposed at some indeterminate time when the garbage collector runs the finalizer of the object. Because an exceptional event might occur that will prevent the finalizer of the object from running, the object should be explicitly disposed instead.
@@ -2166,6 +2178,18 @@ JsonDocument implements IDisposable and needs to be properly disposed. When only
21662178
|CodeFix|True|
21672179
---
21682180

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+
21692193
## [CA2100](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2100): Review SQL queries for security vulnerabilities
21702194

21712195
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: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3507,6 +3507,26 @@
35073507
]
35083508
}
35093509
},
3510+
"CA1876": {
3511+
"id": "CA1876",
3512+
"shortDescription": "Do not use 'AsParallel' in 'foreach'",
3513+
"fullDescription": "Using 'AsParallel()' directly in a 'foreach' loop has no effect. The 'foreach' statement iterates serially through the collection regardless. To parallelize LINQ operations, call 'AsParallel()' earlier in the query chain before other LINQ operators. To parallelize the loop itself, use 'Parallel.ForEach' instead.",
3514+
"defaultLevel": "note",
3515+
"helpUri": "https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1876",
3516+
"properties": {
3517+
"category": "Performance",
3518+
"isEnabledByDefault": true,
3519+
"typeName": "DoNotUseAsParallelInForEachLoopAnalyzer",
3520+
"languages": [
3521+
"C#",
3522+
"Visual Basic"
3523+
],
3524+
"tags": [
3525+
"Telemetry",
3526+
"EnabledRuleInAggressiveMode"
3527+
]
3528+
}
3529+
},
35103530
"CA2000": {
35113531
"id": "CA2000",
35123532
"shortDescription": "Dispose objects before losing scope",
@@ -3870,6 +3890,26 @@
38703890
]
38713891
}
38723892
},
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+
},
38733913
"CA2100": {
38743914
"id": "CA2100",
38753915
"shortDescription": "Review SQL queries for security vulnerabilities",

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ Rule ID | Category | Severity | Notes
77
CA1873 | Performance | Info | AvoidPotentiallyExpensiveCallWhenLoggingAnalyzer, [Documentation](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1873)
88
CA1874 | Performance | Info | UseRegexMembers, [Documentation](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1874)
99
CA1875 | Performance | Info | UseRegexMembers, [Documentation](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1875)
10+
CA1876 | Performance | Info | DoNotUseAsParallelInForEachLoopAnalyzer, [Documentation](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1876)
1011
CA2023 | Reliability | Warning | LoggerMessageDefineAnalyzer, [Documentation](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2023)
1112
CA2024 | Reliability | Warning | DoNotUseEndOfStreamInAsyncMethods, [Documentation](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2024)
1213
CA2025 | Reliability | Disabled | DoNotPassDisposablesIntoUnawaitedTasksAnalyzer, [Documentation](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2025)
1314
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: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,15 @@
195195
<data name="DoNotUseCountWhenAnyCanBeUsedTitle" xml:space="preserve">
196196
<value>Do not use Count() or LongCount() when Any() can be used</value>
197197
</data>
198+
<data name="DoNotUseAsParallelInForEachLoopTitle" xml:space="preserve">
199+
<value>Do not use 'AsParallel' in 'foreach'</value>
200+
</data>
201+
<data name="DoNotUseAsParallelInForEachLoopMessage" xml:space="preserve">
202+
<value>Using 'AsParallel()' directly in a 'foreach' loop has no effect and the loop is not parallelized</value>
203+
</data>
204+
<data name="DoNotUseAsParallelInForEachLoopDescription" xml:space="preserve">
205+
<value>Using 'AsParallel()' directly in a 'foreach' loop has no effect. The 'foreach' statement iterates serially through the collection regardless. To parallelize LINQ operations, call 'AsParallel()' earlier in the query chain before other LINQ operators. To parallelize the loop itself, use 'Parallel.ForEach' instead.</value>
206+
</data>
198207
<data name="PreferConvertToHexStringOverBitConverterTitle" xml:space="preserve">
199208
<value>Prefer 'Convert.ToHexString' and 'Convert.ToHexStringLower' over call chains based on 'BitConverter.ToString'</value>
200209
</data>
@@ -1637,6 +1646,15 @@
16371646
<data name="DoNotUseWhenAllWithSingleTaskFix" xml:space="preserve">
16381647
<value>Replace 'WhenAll' call with argument</value>
16391648
</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>
16401658
<data name="UseStringEqualsOverStringCompareCodeFixTitle" xml:space="preserve">
16411659
<value>Use 'string.Equals'</value>
16421660
</data>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
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 Microsoft.CodeAnalysis;
8+
using Microsoft.CodeAnalysis.Diagnostics;
9+
using Microsoft.CodeAnalysis.Operations;
10+
11+
namespace Microsoft.NetCore.Analyzers.Performance
12+
{
13+
using static MicrosoftNetCoreAnalyzersResources;
14+
15+
/// <summary>
16+
/// CA1876: <inheritdoc cref="DoNotUseAsParallelInForEachLoopTitle"/>
17+
/// Analyzer to detect misuse of AsParallel() when used directly in a foreach loop.
18+
/// </summary>
19+
[DiagnosticAnalyzer(LanguageNames.CSharp, LanguageNames.VisualBasic)]
20+
public sealed class DoNotUseAsParallelInForEachLoopAnalyzer : DiagnosticAnalyzer
21+
{
22+
internal const string RuleId = "CA1876";
23+
24+
internal static readonly DiagnosticDescriptor Rule = DiagnosticDescriptorHelper.Create(
25+
RuleId,
26+
CreateLocalizableResourceString(nameof(DoNotUseAsParallelInForEachLoopTitle)),
27+
CreateLocalizableResourceString(nameof(DoNotUseAsParallelInForEachLoopMessage)),
28+
DiagnosticCategory.Performance,
29+
RuleLevel.IdeSuggestion,
30+
description: CreateLocalizableResourceString(nameof(DoNotUseAsParallelInForEachLoopDescription)),
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+
context.RegisterCompilationStartAction(OnCompilationStart);
41+
}
42+
43+
private static void OnCompilationStart(CompilationStartAnalysisContext context)
44+
{
45+
var typeProvider = WellKnownTypeProvider.GetOrCreate(context.Compilation);
46+
47+
// Get the ParallelEnumerable type
48+
var parallelEnumerableType = typeProvider.GetOrCreateTypeByMetadataName(WellKnownTypeNames.SystemLinqParallelEnumerable);
49+
50+
if (parallelEnumerableType == null)
51+
{
52+
return;
53+
}
54+
55+
// Get all AsParallel methods - use SymbolEqualityComparer for proper comparison
56+
var asParallelMethods = ImmutableHashSet.CreateRange<IMethodSymbol>(
57+
SymbolEqualityComparer.Default,
58+
parallelEnumerableType.GetMembers("AsParallel").OfType<IMethodSymbol>());
59+
60+
if (asParallelMethods.IsEmpty)
61+
{
62+
return;
63+
}
64+
65+
context.RegisterOperationAction(ctx => AnalyzeForEachLoop(ctx, asParallelMethods), OperationKind.Loop);
66+
}
67+
68+
private static void AnalyzeForEachLoop(OperationAnalysisContext context, ImmutableHashSet<IMethodSymbol> asParallelMethods)
69+
{
70+
if (context.Operation is not IForEachLoopOperation forEachLoop)
71+
{
72+
return;
73+
}
74+
75+
// Check if the collection is a direct result of AsParallel()
76+
var collection = forEachLoop.Collection;
77+
78+
// Walk up conversions to find the actual operation
79+
while (collection is IConversionOperation conversion)
80+
{
81+
collection = conversion.Operand;
82+
}
83+
84+
// Check if this is an invocation of AsParallel
85+
if (collection is IInvocationOperation invocation)
86+
{
87+
var targetMethod = invocation.TargetMethod;
88+
89+
// For extension methods, we need to check the ReducedFrom or the original method
90+
var methodToCheck = targetMethod.ReducedFrom ?? targetMethod;
91+
92+
if (asParallelMethods.Contains(methodToCheck.OriginalDefinition))
93+
{
94+
// Report diagnostic on the AsParallel call
95+
context.ReportDiagnostic(invocation.CreateDiagnostic(Rule));
96+
}
97+
}
98+
}
99+
}
100+
}
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+
}

0 commit comments

Comments
 (0)