Skip to content
Draft
41 changes: 41 additions & 0 deletions docfx/analyzers/VSTHRD003.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,47 @@ When required to await a task that was started earlier, start it within a delega
`JoinableTaskFactory.RunAsync`, storing the resulting `JoinableTask` in a field or variable.
You can safely await the `JoinableTask` later.

## Suppressing warnings for completed tasks

If you have a property, method, or field that returns a pre-completed task (such as a cached task with a known value),
you can suppress this warning by applying the `[CompletedTask]` attribute to the member.
This attribute is automatically included when you install the `Microsoft.VisualStudio.Threading.Analyzers` package.

```csharp
[Microsoft.VisualStudio.Threading.CompletedTask]
private static readonly Task<bool> TrueTask = Task.FromResult(true);

async Task MyMethodAsync()
{
await TrueTask; // No warning - TrueTask is marked as a completed task
}
```

**Important restrictions:**
- Fields must be marked `readonly` when using this attribute
- Properties must not have non-private setters (getter-only or private setters are allowed)

### Marking external types

You can also apply the attribute at the assembly level to mark members in external types that you don't control:

```csharp
[assembly: Microsoft.VisualStudio.Threading.CompletedTask(Member = "ExternalLibrary.ExternalClass.CompletedTaskProperty")]
```

This is useful when you're using third-party libraries that have pre-completed tasks but aren't annotated with the attribute.
The `Member` property should contain the fully qualified name of the member in the format `Namespace.TypeName.MemberName`.

The analyzer already recognizes the following as safe to await without the attribute:
- `Task.CompletedTask`
- `Task.FromResult(...)`
- `Task.FromCanceled(...)`
- `Task.FromException(...)`
- `TplExtensions.CompletedTask`
- `TplExtensions.CanceledTask`
- `TplExtensions.TrueTask`
- `TplExtensions.FalseTask`

## Simple examples of patterns that are flagged by this analyzer

The following example would likely deadlock if `MyMethod` were called on the main thread,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,15 @@ public class VSTHRD003UseJtfRunAsyncAnalyzer : DiagnosticAnalyzer
{
public const string Id = "VSTHRD003";

public static readonly DiagnosticDescriptor InvalidAttributeUseDescriptor = new DiagnosticDescriptor(
id: Id,
title: new LocalizableResourceString(nameof(Strings.VSTHRD003InvalidAttributeUse_Title), Strings.ResourceManager, typeof(Strings)),
messageFormat: new LocalizableResourceString(nameof(Strings.VSTHRD003InvalidAttributeUse_MessageFormat), Strings.ResourceManager, typeof(Strings)),
helpLinkUri: Utils.GetHelpLink(Id),
category: "Usage",
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true);

internal static readonly DiagnosticDescriptor Descriptor = new DiagnosticDescriptor(
id: Id,
title: new LocalizableResourceString(nameof(Strings.VSTHRD003_Title), Strings.ResourceManager, typeof(Strings)),
Expand All @@ -54,7 +63,7 @@ public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics
{
get
{
return ImmutableArray.Create(Descriptor);
return ImmutableArray.Create(InvalidAttributeUseDescriptor, Descriptor);
}
}

Expand All @@ -69,20 +78,77 @@ public override void Initialize(AnalysisContext context)
context.RegisterSyntaxNodeAction(Utils.DebuggableWrapper(this.AnalyzeArrowExpressionClause), SyntaxKind.ArrowExpressionClause);
context.RegisterSyntaxNodeAction(Utils.DebuggableWrapper(this.AnalyzeLambdaExpression), SyntaxKind.SimpleLambdaExpression);
context.RegisterSyntaxNodeAction(Utils.DebuggableWrapper(this.AnalyzeLambdaExpression), SyntaxKind.ParenthesizedLambdaExpression);
context.RegisterSymbolAction(Utils.DebuggableWrapper(this.AnalyzeSymbolForInvalidAttributeUse), SymbolKind.Field, SymbolKind.Property, SymbolKind.Method);
}

private static bool IsSymbolAlwaysOkToAwait(ISymbol? symbol)
private static bool IsSymbolAlwaysOkToAwait(ISymbol? symbol, Compilation compilation)
{
if (symbol is IFieldSymbol field)
if (symbol is null)
{
// Allow the TplExtensions.CompletedTask and related fields.
if (field.ContainingType.Name == Types.TplExtensions.TypeName && field.BelongsToNamespace(Types.TplExtensions.Namespace) &&
(field.Name == Types.TplExtensions.CompletedTask || field.Name == Types.TplExtensions.CanceledTask || field.Name == Types.TplExtensions.TrueTask || field.Name == Types.TplExtensions.FalseTask))
return false;
}

// Check if the symbol has the CompletedTaskAttribute directly applied
if (symbol.GetAttributes().Any(attr =>
attr.AttributeClass?.Name == Types.CompletedTaskAttribute.TypeName &&
attr.AttributeClass.BelongsToNamespace(Types.CompletedTaskAttribute.Namespace)))
{
// Validate that the attribute is used correctly
if (symbol is IFieldSymbol fieldSymbol)
{
return true;
// Fields must be readonly
if (!fieldSymbol.IsReadOnly)
{
return false;
}
}
else if (symbol is IPropertySymbol propertySymbol)
{
// Properties must not have non-private setters
// Init accessors are only allowed if the property itself is private
if (propertySymbol.SetMethod is not null)
{
if (propertySymbol.SetMethod.IsInitOnly)
{
// Init accessor - only allowed if property is private
if (propertySymbol.DeclaredAccessibility != Accessibility.Private)
{
return false;
}
}
else if (propertySymbol.SetMethod.DeclaredAccessibility != Accessibility.Private)
{
// Regular setter must be private
return false;
}
}
}

return true;
}
else if (symbol is IPropertySymbol property)

// Check for assembly-level CompletedTaskAttribute
foreach (AttributeData assemblyAttr in compilation.Assembly.GetAttributes())
{
if (assemblyAttr.AttributeClass?.Name == Types.CompletedTaskAttribute.TypeName &&
assemblyAttr.AttributeClass.BelongsToNamespace(Types.CompletedTaskAttribute.Namespace))
{
// Look for the Member named argument
foreach (KeyValuePair<string, TypedConstant> namedArg in assemblyAttr.NamedArguments)
{
if (namedArg.Key == "Member" && namedArg.Value.Value is string memberName)
{
// Check if this symbol matches the specified member name
if (IsSymbolMatchingMemberName(symbol, memberName))
{
return true;
}
}
}
}
}

if (symbol is IPropertySymbol property)
{
// Explicitly allow Task.CompletedTask
if (property.ContainingType.Name == Types.Task.TypeName && property.BelongsToNamespace(Types.Task.Namespace) &&
Expand All @@ -95,6 +161,102 @@ private static bool IsSymbolAlwaysOkToAwait(ISymbol? symbol)
return false;
}

private static bool IsSymbolMatchingMemberName(ISymbol symbol, string memberName)
{
// Build the fully qualified name of the symbol
string fullyQualifiedName = GetFullyQualifiedName(symbol);

// Compare with the member name (case-sensitive)
return string.Equals(fullyQualifiedName, memberName, StringComparison.Ordinal);
}

private static string GetFullyQualifiedName(ISymbol symbol)
{
if (symbol.ContainingType is null)
{
return symbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
}

// For members (properties, fields, methods), construct: Namespace.TypeName.MemberName
List<string> parts = new List<string>();

// Add member name
parts.Add(symbol.Name);

// Add containing type hierarchy
INamedTypeSymbol? currentType = symbol.ContainingType;
while (currentType is not null)
{
parts.Insert(0, currentType.Name);
currentType = currentType.ContainingType;
}

// Add namespace
if (symbol.ContainingNamespace is not null && !symbol.ContainingNamespace.IsGlobalNamespace)
{
parts.Insert(0, symbol.ContainingNamespace.ToDisplayString());
}

return string.Join(".", parts);
}

private void AnalyzeSymbolForInvalidAttributeUse(SymbolAnalysisContext context)
{
ISymbol symbol = context.Symbol;

// Check if the symbol has the CompletedTaskAttribute
AttributeData? completedTaskAttr = symbol.GetAttributes().FirstOrDefault(attr =>
attr.AttributeClass?.Name == Types.CompletedTaskAttribute.TypeName &&
attr.AttributeClass.BelongsToNamespace(Types.CompletedTaskAttribute.Namespace));

if (completedTaskAttr is null)
{
return;
}

string? errorMessage = null;

if (symbol is IFieldSymbol fieldSymbol)
{
// Fields must be readonly
if (!fieldSymbol.IsReadOnly)
{
errorMessage = Strings.VSTHRD003InvalidAttributeUse_FieldNotReadonly;
}
}
else if (symbol is IPropertySymbol propertySymbol)
{
// Check for init accessor (which is a special kind of setter)
if (propertySymbol.SetMethod is not null)
{
// Init accessors are only allowed if the property itself is private
if (propertySymbol.SetMethod.IsInitOnly)
{
if (propertySymbol.DeclaredAccessibility != Accessibility.Private)
{
errorMessage = Strings.VSTHRD003InvalidAttributeUse_PropertyWithNonPrivateInit;
}
}
else if (propertySymbol.SetMethod.DeclaredAccessibility != Accessibility.Private)
{
// Non-private setters are not allowed
errorMessage = Strings.VSTHRD003InvalidAttributeUse_PropertyWithNonPrivateSetter;
}
}
}

// Methods are always allowed
if (errorMessage is not null)
{
// Report diagnostic on the attribute location
Location? location = completedTaskAttr.ApplicationSyntaxReference?.GetSyntax(context.CancellationToken).GetLocation();
if (location is not null)
{
context.ReportDiagnostic(Diagnostic.Create(InvalidAttributeUseDescriptor, location, errorMessage));
}
}
}

private void AnalyzeArrowExpressionClause(SyntaxNodeAnalysisContext context)
{
var arrowExpressionClause = (ArrowExpressionClauseSyntax)context.Node;
Expand Down Expand Up @@ -183,7 +345,7 @@ private void AnalyzeAwaitExpression(SyntaxNodeAnalysisContext context)
symbolType = localSymbol.Type;
dataflowAnalysisCompatibleVariable = true;
break;
case IPropertySymbol propertySymbol when !IsSymbolAlwaysOkToAwait(propertySymbol):
case IPropertySymbol propertySymbol when !IsSymbolAlwaysOkToAwait(propertySymbol, context.Compilation):
symbolType = propertySymbol.Type;

if (focusedExpression is MemberAccessExpressionSyntax memberAccessExpression)
Expand Down Expand Up @@ -277,7 +439,7 @@ private void AnalyzeAwaitExpression(SyntaxNodeAnalysisContext context)
}

ISymbol? definition = declarationSemanticModel.GetSymbolInfo(memberAccessSyntax, cancellationToken).Symbol;
if (IsSymbolAlwaysOkToAwait(definition))
if (IsSymbolAlwaysOkToAwait(definition, context.Compilation))
{
return null;
}
Expand All @@ -288,6 +450,12 @@ private void AnalyzeAwaitExpression(SyntaxNodeAnalysisContext context)

break;
case IMethodSymbol methodSymbol:
// Check if the method itself has the CompletedTaskAttribute
if (IsSymbolAlwaysOkToAwait(methodSymbol, context.Compilation))
{
return null;
}

if (Utils.IsTask(methodSymbol.ReturnType) && focusedExpression is InvocationExpressionSyntax invocationExpressionSyntax)
{
// Consider all arguments
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

#if !COMPLETEDTASKATTRIBUTE_INCLUDED
#define COMPLETEDTASKATTRIBUTE_INCLUDED

namespace Microsoft.VisualStudio.Threading;

/// <summary>
/// Indicates that a property, method, or field returns a task that is already completed.
/// This suppresses VSTHRD003 warnings when awaiting the returned task.
/// </summary>
/// <remarks>
/// <para>
/// Apply this attribute to properties, methods, or fields that return cached, pre-completed tasks
/// such as singleton instances with well-known immutable values.
/// The VSTHRD003 analyzer will not report warnings when these members are awaited,
/// as awaiting an already-completed task does not pose a risk of deadlock.
/// </para>
/// <para>
/// This attribute can also be applied at the assembly level to mark members in external types
/// that you don't control:
/// <code>
/// [assembly: CompletedTask(Member = "System.Threading.Tasks.TplExtensions.TrueTask")]
/// </code>
/// </para>
/// </remarks>
[System.AttributeUsage(System.AttributeTargets.Property | System.AttributeTargets.Method | System.AttributeTargets.Field | System.AttributeTargets.Assembly, Inherited = false, AllowMultiple = true)]
#pragma warning disable SA1649 // File name should match first type name
internal sealed class CompletedTaskAttribute : System.Attribute
{
/// <summary>
/// Initializes a new instance of the <see cref="CompletedTaskAttribute"/> class.
/// </summary>
public CompletedTaskAttribute()
{
}

/// <summary>
/// Gets or sets the fully qualified name of the member that returns a completed task.
/// This is only used when the attribute is applied at the assembly level.
/// </summary>
/// <remarks>
/// The format should be: "Namespace.TypeName.MemberName".
/// For example: "System.Threading.Tasks.TplExtensions.TrueTask".
/// </remarks>
public string? Member { get; set; }
}
#pragma warning restore SA1649 // File name should match first type name

#endif
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
<?xml version="1.0" encoding="utf-8" ?>
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<ItemGroup>
<AdditionalFiles Include="$(MSBuildThisFileDirectory)AdditionalFiles\**">
<AdditionalFiles Include="$(MSBuildThisFileDirectory)AdditionalFiles\*.txt">
<Visible>false</Visible>
</AdditionalFiles>
<Compile Include="$(MSBuildThisFileDirectory)AdditionalFiles\*.cs" Link="%(Filename)%(Extension)">
<Visible>false</Visible>
</Compile>
</ItemGroup>
</Project>
18 changes: 18 additions & 0 deletions src/Microsoft.VisualStudio.Threading.Analyzers/Strings.resx
Original file line number Diff line number Diff line change
Expand Up @@ -353,4 +353,22 @@ Start the work within this context, or use JoinableTaskFactory.RunAsync to start
<data name="VSTHRD115_CodeFix_UseFactory_Title" xml:space="preserve">
<value>Use 'JoinableTaskContext.CreateNoOpContext' instead.</value>
</data>
<data name="VSTHRD003InvalidAttributeUse_Title" xml:space="preserve">
<value>Invalid use of CompletedTaskAttribute</value>
</data>
<data name="VSTHRD003InvalidAttributeUse_MessageFormat" xml:space="preserve">
<value>CompletedTaskAttribute can only be applied to readonly fields, properties without non-private setters, or methods. {0}</value>
</data>
<data name="VSTHRD003InvalidAttributeUse_FieldNotReadonly" xml:space="preserve">
<value>Fields must be readonly.</value>
<comment>Error message shown when the CompletedTaskAttribute is applied to a field that is not marked as readonly. The attribute is only valid on readonly fields to ensure the task value cannot be changed.</comment>
</data>
<data name="VSTHRD003InvalidAttributeUse_PropertyWithNonPrivateSetter" xml:space="preserve">
<value>Properties must not have non-private setters.</value>
<comment>Error message shown when the CompletedTaskAttribute is applied to a property that has a public, internal, or protected setter. The attribute is only valid on properties with private setters or no setter (getter-only) to ensure the task value cannot be changed from outside the class.</comment>
</data>
<data name="VSTHRD003InvalidAttributeUse_PropertyWithNonPrivateInit" xml:space="preserve">
<value>Properties with init accessors must be private.</value>
<comment>Error message shown when the CompletedTaskAttribute is applied to a property that has an init accessor and the property itself is not private (i.e., it's public, internal, or protected). Init accessors allow setting the property during object initialization. When using this attribute on a property with an init accessor, the entire property must be declared as private to ensure the task value cannot be changed from outside the class.</comment>
</data>
</root>
7 changes: 7 additions & 0 deletions src/Microsoft.VisualStudio.Threading.Analyzers/Types.cs
Original file line number Diff line number Diff line change
Expand Up @@ -246,4 +246,11 @@ public static class TypeLibTypeAttribute

public static readonly ImmutableArray<string> Namespace = Namespaces.SystemRuntimeInteropServices;
}

public static class CompletedTaskAttribute
{
public const string TypeName = "CompletedTaskAttribute";

public static readonly ImmutableArray<string> Namespace = Namespaces.MicrosoftVisualStudioThreading;
}
}
Loading