From 66b9c356a630d629e274ab13778b81ac6d642c8c Mon Sep 17 00:00:00 2001 From: Andrew Arnott Date: Thu, 27 Aug 2026 15:49:03 -0600 Subject: [PATCH 1/2] Fix VSTHRD103 task flow false positives Recognize variance-aware generic task flow through synchronous composition APIs so async alternatives are not incorrectly preferred. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../CSharpCommonInterest.cs | 145 ++++++++++++++++++ .../VSTHRD002UseJtfRunAnalyzer.cs | 1 + .../VSTHRD103UseAsyncOptionAnalyzer.cs | 3 +- .../VSTHRD002UseJtfRunAnalyzerTests.cs | 38 +++++ .../VSTHRD103UseAsyncOptionAnalyzerTests.cs | 127 +++++++++++++++ 5 files changed, 313 insertions(+), 1 deletion(-) diff --git a/src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs b/src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs index 99c2f6b1f..815287766 100644 --- a/src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs +++ b/src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs @@ -390,6 +390,151 @@ internal static bool IsApplicableAsyncAlternative( return SymbolEqualityComparer.Default.Equals(applicableDefinition, candidateDefinition); } + /// + /// Determines whether a method carries async-compatible values from its parameters into its return value. + /// + /// + /// Synchronous higher-order methods may compose asynchronous work without synchronously blocking on it. + /// For example, Enumerable.Select can project values into an IEnumerable<Task>. + /// In such cases, an Async-suffixed method is not necessarily a preferable alternative. + /// + internal static bool ReturnsAsyncCompatibleValuesFromParameters(IMethodSymbol method) + { + IMethodSymbol constructedMethod = method; + IMethodSymbol methodDefinition = constructedMethod.OriginalDefinition; + foreach (ITypeParameterSymbol typeParameter in methodDefinition.TypeParameters) + { + if (IsAsyncCompatibleTypeParameterFlow(typeParameter)) + { + return true; + } + } + + for (INamedTypeSymbol? containingType = methodDefinition.ContainingType; containingType is object; containingType = containingType.ContainingType) + { + foreach (ITypeParameterSymbol typeParameter in containingType.TypeParameters) + { + if (IsAsyncCompatibleTypeParameterFlow(typeParameter)) + { + return true; + } + } + } + + return false; + + bool IsAsyncCompatibleTypeParameterFlow(ITypeParameterSymbol typeParameter) + => ContainsTypeParameterInOutputPosition(methodDefinition.ReturnType, typeParameter) + && IsUsedByParameter(typeParameter) + && GetConstructedTypeArgument(constructedMethod, typeParameter) is { } typeArgument + && ContainsAsyncCompatibleType(typeArgument); + + bool IsUsedByParameter(ITypeParameterSymbol typeParameter) + { + if (methodDefinition.Parameters.Any( + parameter => parameter.RefKind != RefKind.Out + && ContainsTypeParameterInOutputPosition(parameter.Type, typeParameter))) + { + return true; + } + + IMethodSymbol? unreducedDefinition = method.ReducedFrom?.OriginalDefinition; + return unreducedDefinition is object + && typeParameter.TypeParameterKind == TypeParameterKind.Method + && unreducedDefinition.Parameters.Length > 0 + && ContainsTypeParameterInOutputPosition( + unreducedDefinition.Parameters[0].Type, + unreducedDefinition.TypeParameters[typeParameter.Ordinal]); + } + + static bool ContainsAsyncCompatibleType(ITypeSymbol type, VarianceKind variance = VarianceKind.Out) + { + if (variance != VarianceKind.In && type.IsAsyncCompatibleReturnType()) + { + return true; + } + + if (type is IArrayTypeSymbol arrayType) + { + return ContainsAsyncCompatibleType(arrayType.ElementType, variance); + } + + if (type is INamedTypeSymbol namedType) + { + for (int i = 0; i < namedType.TypeArguments.Length; i++) + { + VarianceKind typeArgumentVariance = i < namedType.OriginalDefinition.TypeParameters.Length + ? namedType.OriginalDefinition.TypeParameters[i].Variance + : VarianceKind.None; + if (ContainsAsyncCompatibleType(namedType.TypeArguments[i], ComposeVariance(variance, typeArgumentVariance))) + { + return true; + } + } + } + + return false; + } + + static bool ContainsTypeParameterInOutputPosition( + ITypeSymbol type, + ITypeParameterSymbol typeParameter, + VarianceKind variance = VarianceKind.Out) + { + if (variance != VarianceKind.In && SymbolEqualityComparer.Default.Equals(type, typeParameter)) + { + return true; + } + + if (type is IArrayTypeSymbol arrayType) + { + return ContainsTypeParameterInOutputPosition(arrayType.ElementType, typeParameter, variance); + } + + if (type is INamedTypeSymbol namedType) + { + for (int i = 0; i < namedType.TypeArguments.Length; i++) + { + VarianceKind typeArgumentVariance = i < namedType.OriginalDefinition.TypeParameters.Length + ? namedType.OriginalDefinition.TypeParameters[i].Variance + : VarianceKind.None; + if (ContainsTypeParameterInOutputPosition( + namedType.TypeArguments[i], + typeParameter, + ComposeVariance(variance, typeArgumentVariance))) + { + return true; + } + } + } + + return false; + } + + static VarianceKind ComposeVariance(VarianceKind outer, VarianceKind inner) + => outer == VarianceKind.None || inner == VarianceKind.None + ? VarianceKind.None + : outer == inner ? VarianceKind.Out : VarianceKind.In; + + static ITypeSymbol? GetConstructedTypeArgument(IMethodSymbol constructedMethod, ITypeParameterSymbol typeParameter) + { + if (typeParameter.TypeParameterKind == TypeParameterKind.Method) + { + return constructedMethod.TypeArguments[typeParameter.Ordinal]; + } + + for (INamedTypeSymbol? containingType = constructedMethod.ContainingType; containingType is object; containingType = containingType.ContainingType) + { + if (SymbolEqualityComparer.Default.Equals(containingType.OriginalDefinition, typeParameter.ContainingSymbol)) + { + return containingType.TypeArguments[typeParameter.Ordinal]; + } + } + + return null; + } + } + /// /// Determines whether a blocking member access has a receiver that is provably complete. /// diff --git a/src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD002UseJtfRunAnalyzer.cs b/src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD002UseJtfRunAnalyzer.cs index 09c179b8e..19215891b 100644 --- a/src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD002UseJtfRunAnalyzer.cs +++ b/src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD002UseJtfRunAnalyzer.cs @@ -172,6 +172,7 @@ private static void AnalyzeInvocation( && !methodsExcludedFromVSTHRD103.Contains(methodDefinition) && !invokedMethod.Name.EndsWith(VSTHRD200UseAsyncNamingConventionAnalyzer.MandatoryAsyncSuffix, StringComparison.CurrentCulture) && !invokedMethod.HasAsyncCompatibleReturnType() + && !CSharpCommonInterest.ReturnsAsyncCompatibleValuesFromParameters(invokedMethod) && IsInTaskReturningMethodOrDelegate(context) && HasAsyncAlternative(context, invocationExpressionSyntax, invokedMethod); if (!isBuiltInSyncBlockingMethod diff --git a/src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD103UseAsyncOptionAnalyzer.cs b/src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD103UseAsyncOptionAnalyzer.cs index 3d76461be..788fdc60f 100644 --- a/src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD103UseAsyncOptionAnalyzer.cs +++ b/src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD103UseAsyncOptionAnalyzer.cs @@ -145,7 +145,8 @@ MemberBindingExpressionSyntax memberBinding when invocationExpressionSyntax.Firs // Also consider all method calls to check for Async-suffixed alternatives. SymbolInfo symbolInfo = context.SemanticModel.GetSymbolInfo(invocationExpressionSyntax, context.CancellationToken); if (symbolInfo.Symbol is IMethodSymbol methodSymbol && !methodSymbol.Name.EndsWith(VSTHRD200UseAsyncNamingConventionAnalyzer.MandatoryAsyncSuffix, StringComparison.CurrentCulture) && - !methodSymbol.HasAsyncCompatibleReturnType()) + !methodSymbol.HasAsyncCompatibleReturnType() && + !CSharpCommonInterest.ReturnsAsyncCompatibleValuesFromParameters(methodSymbol)) { string asyncMethodName = methodSymbol.Name + VSTHRD200UseAsyncNamingConventionAnalyzer.MandatoryAsyncSuffix; diff --git a/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/VSTHRD002UseJtfRunAnalyzerTests.cs b/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/VSTHRD002UseJtfRunAnalyzerTests.cs index 9b3d5a93b..94ff6a47f 100644 --- a/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/VSTHRD002UseJtfRunAnalyzerTests.cs +++ b/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/VSTHRD002UseJtfRunAnalyzerTests.cs @@ -1345,6 +1345,44 @@ Task FAsync(Task task) { await verifyTest.RunAsync(); } + [Fact] + public async Task ConfiguredTaskFlowMethodInTaskReturningMethodReports() + { + string test = """ + using System; + using System.Collections.Generic; + using System.Threading.Tasks; + + static class Helpers + { + internal static IEnumerable Project( + IEnumerable source, + Func selector) => throw null; + + internal static Task> ProjectAsync( + IEnumerable source, + Func selector) => throw null; + } + + class Test + { + Task FAsync(IEnumerable values) + { + IEnumerable tasks = Helpers.[|Project|](values, value => Task.CompletedTask); + return Task.WhenAll(tasks); + } + } + """; + + var verifyTest = new CSVerify.Test + { + TestCode = test, + FixedCode = test, + }; + verifyTest.TestState.AdditionalFiles.Add(("vs-threading.SyncBlockingMethods.txt", "[Helpers]::Project")); + await verifyTest.RunAsync(); + } + [Fact] public async Task ConditionalTaskWaitInSynchronousMethodReports() { diff --git a/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/VSTHRD103UseAsyncOptionAnalyzerTests.cs b/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/VSTHRD103UseAsyncOptionAnalyzerTests.cs index 677b716f6..57de9a0db 100644 --- a/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/VSTHRD103UseAsyncOptionAnalyzerTests.cs +++ b/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/VSTHRD103UseAsyncOptionAnalyzerTests.cs @@ -1983,6 +1983,133 @@ await CSVerify.VerifyAnalyzerAsync( CSVerify.Diagnostic(Descriptor).WithLocation(0).WithArguments("Join", "JoinAsync")); } + [Fact] + public async Task TaskProducingProjectionDoesNotGenerateWarning() + { + string test = """ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Threading.Tasks; + + class Item { } + + class Test + { + async Task ProcessAllAsync(IEnumerable items) + { + await Task.WhenAll(items.Select(ProcessAsync)); + + IEnumerable tasks = items.Select(ProcessAsync); + await Task.WhenAll(tasks); + } + + Task ProcessAsync(Item item) => Task.CompletedTask; + } + + static class AsyncEnumerableExtensions + { + public static Task> SelectAsync( + this IEnumerable source, + Func selector) => Task.FromResult(source.Select(selector)); + } + """; + + await CSVerify.VerifyAnalyzerAsync(test); + } + + [Fact] + public async Task TaskLikeValuesFlowingThroughContainersDoNotGenerateWarning() + { + string test = """ + using System; + using System.Collections.Generic; + using System.Threading.Tasks; + + class Test + { + async Task ProcessAllAsync(IEnumerable items, Task existingTask) + { + IReadOnlyList> valueTasks = Map(items, ProcessAsync); + (Task, int) wrappedTask = Wrap(existingTask); + Task[] wrappedTasks = WrapInArray(existingTask); + Task[] extensionWrappedTasks = existingTask.WrapInArray(); + } + + ValueTask ProcessAsync(int item) => new ValueTask(item); + + static IReadOnlyList Map( + IEnumerable source, + Func selector) => throw null; + + static Task> MapAsync( + IEnumerable source, + Func selector) => throw null; + + static (T, int) Wrap(T value) => (value, 0); + + static Task<(T, int)> WrapAsync(T value) => Task.FromResult((value, 0)); + + static T[] WrapInArray(T value) => new[] { value }; + + static Task WrapInArrayAsync(T value) => Task.FromResult(new[] { value }); + } + + static class TaskCarryingExtensions + { + internal static T[] WrapInArray(this T value) => new[] { value }; + + internal static Task WrapInArrayAsync(this T value) => Task.FromResult(new[] { value }); + } + """; + + await CSVerify.VerifyAnalyzerAsync(test); + } + + [Fact] + public async Task AsyncAlternativeStillWarnsWhenTaskLikeValuesDoNotFlowToReturnValue() + { + string test = """ + using System; + using System.Collections.Generic; + using System.Threading.Tasks; + + class Test + { + async Task ProcessAllAsync() + { + IEnumerable tasks = {|#0:GetTasks|}(); + {|#1:Run|}(() => Task.CompletedTask); + (Task, int) wrappedTask = {|#2:Wrap|}(Task.CompletedTask); + IEnumerable loadedTasks = {|#3:Load|}(task => { }); + } + + static IEnumerable GetTasks() => throw null; + + static Task> GetTasksAsync() => throw null; + + static void Run(Func action) { } + + static Task RunAsync(Func action) => action(); + + static (Task, int) Wrap(Task task) => (task, 0); + + static Task<(Task, int)> WrapAsync(Task task) => Task.FromResult((task, 0)); + + static IEnumerable Load(Action consumer) => throw null; + + static Task> LoadAsync(Action consumer) => throw null; + } + """; + + await CSVerify.VerifyAnalyzerAsync( + test, + CSVerify.Diagnostic(Descriptor).WithLocation(0).WithArguments("GetTasks", "GetTasksAsync"), + CSVerify.Diagnostic(Descriptor).WithLocation(1).WithArguments("Run", "RunAsync"), + CSVerify.Diagnostic(Descriptor).WithLocation(2).WithArguments("Wrap", "WrapAsync"), + CSVerify.Diagnostic(Descriptor).WithLocation(3).WithArguments("Load", "LoadAsync")); + } + [Fact] public async Task TaskGetAwaiterGetResultInTaskReturningMethodGeneratesWarning() { From 6cf12b7b3bfb8b3c956063ec8e5e6d43afc3ffe7 Mon Sep 17 00:00:00 2001 From: Andrew Arnott Date: Thu, 27 Aug 2026 15:57:08 -0600 Subject: [PATCH 2/2] Avoid async warnings in task flow tests Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../VSTHRD103UseAsyncOptionAnalyzerTests.cs | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/VSTHRD103UseAsyncOptionAnalyzerTests.cs b/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/VSTHRD103UseAsyncOptionAnalyzerTests.cs index 57de9a0db..4bac4f3a4 100644 --- a/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/VSTHRD103UseAsyncOptionAnalyzerTests.cs +++ b/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/VSTHRD103UseAsyncOptionAnalyzerTests.cs @@ -2028,12 +2028,13 @@ public async Task TaskLikeValuesFlowingThroughContainersDoNotGenerateWarning() class Test { - async Task ProcessAllAsync(IEnumerable items, Task existingTask) + Task ProcessAllAsync(IEnumerable items, Task existingTask) { - IReadOnlyList> valueTasks = Map(items, ProcessAsync); - (Task, int) wrappedTask = Wrap(existingTask); - Task[] wrappedTasks = WrapInArray(existingTask); - Task[] extensionWrappedTasks = existingTask.WrapInArray(); + _ = Map(items, ProcessAsync); + _ = Wrap(existingTask); + _ = WrapInArray(existingTask); + _ = existingTask.WrapInArray(); + return Task.CompletedTask; } ValueTask ProcessAsync(int item) => new ValueTask(item); @@ -2076,12 +2077,13 @@ public async Task AsyncAlternativeStillWarnsWhenTaskLikeValuesDoNotFlowToReturnV class Test { - async Task ProcessAllAsync() + Task ProcessAllAsync() { - IEnumerable tasks = {|#0:GetTasks|}(); + _ = {|#0:GetTasks|}(); {|#1:Run|}(() => Task.CompletedTask); - (Task, int) wrappedTask = {|#2:Wrap|}(Task.CompletedTask); - IEnumerable loadedTasks = {|#3:Load|}(task => { }); + _ = {|#2:Wrap|}(Task.CompletedTask); + _ = {|#3:Load|}(task => { }); + return Task.CompletedTask; } static IEnumerable GetTasks() => throw null;