From 91a1e3636f8305fc38c35fc1eca5ef0a93c5e7a9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 21 Nov 2025 05:58:02 +0000 Subject: [PATCH 1/9] Initial plan From b9f60ecf3b594fb58facd3d8ed53a947e547b2bf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 21 Nov 2025 06:07:05 +0000 Subject: [PATCH 2/9] Add CompletedTaskAttribute support for VSTHRD003 Co-authored-by: drewnoakes <350947+drewnoakes@users.noreply.github.com> --- .../VSTHRD003UseJtfRunAsyncAnalyzer.cs | 14 ++ .../AdditionalFiles/CompletedTaskAttribute.cs | 33 +++ ...t.VisualStudio.Threading.Analyzers.targets | 5 +- .../Types.cs | 7 + .../VSTHRD003UseJtfRunAsyncAnalyzerTests.cs | 189 ++++++++++++++++++ 5 files changed, 247 insertions(+), 1 deletion(-) create mode 100644 src/Microsoft.VisualStudio.Threading.Analyzers.CodeFixes/buildTransitive/AdditionalFiles/CompletedTaskAttribute.cs diff --git a/src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD003UseJtfRunAsyncAnalyzer.cs b/src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD003UseJtfRunAsyncAnalyzer.cs index 19864c651..b9e55facb 100644 --- a/src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD003UseJtfRunAsyncAnalyzer.cs +++ b/src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD003UseJtfRunAsyncAnalyzer.cs @@ -73,6 +73,14 @@ public override void Initialize(AnalysisContext context) private static bool IsSymbolAlwaysOkToAwait(ISymbol? symbol) { + // Check if the symbol has the CompletedTaskAttribute + if (symbol?.GetAttributes().Any(attr => + attr.AttributeClass?.Name == Types.CompletedTaskAttribute.TypeName && + attr.AttributeClass.BelongsToNamespace(Types.CompletedTaskAttribute.Namespace)) == true) + { + return true; + } + if (symbol is IFieldSymbol field) { // Allow the TplExtensions.CompletedTask and related fields. @@ -288,6 +296,12 @@ private void AnalyzeAwaitExpression(SyntaxNodeAnalysisContext context) break; case IMethodSymbol methodSymbol: + // Check if the method itself has the CompletedTaskAttribute + if (IsSymbolAlwaysOkToAwait(methodSymbol)) + { + return null; + } + if (Utils.IsTask(methodSymbol.ReturnType) && focusedExpression is InvocationExpressionSyntax invocationExpressionSyntax) { // Consider all arguments diff --git a/src/Microsoft.VisualStudio.Threading.Analyzers.CodeFixes/buildTransitive/AdditionalFiles/CompletedTaskAttribute.cs b/src/Microsoft.VisualStudio.Threading.Analyzers.CodeFixes/buildTransitive/AdditionalFiles/CompletedTaskAttribute.cs new file mode 100644 index 000000000..d10179d45 --- /dev/null +++ b/src/Microsoft.VisualStudio.Threading.Analyzers.CodeFixes/buildTransitive/AdditionalFiles/CompletedTaskAttribute.cs @@ -0,0 +1,33 @@ +// 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 + +namespace Microsoft.VisualStudio.Threading; + +/// +/// Indicates that a property, method, or field returns a task that is already completed. +/// This suppresses VSTHRD003 warnings when awaiting the returned task. +/// +/// +/// 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. +/// +[System.AttributeUsage(System.AttributeTargets.Property | System.AttributeTargets.Method | System.AttributeTargets.Field, Inherited = false, AllowMultiple = false)] +internal sealed class CompletedTaskAttribute : System.Attribute +{ +} + +#pragma warning disable SA1403 // File may only contain a single namespace +#pragma warning disable SA1649 // File name should match first type name +internal static class CompletedTaskAttributeDefinition +{ + internal const bool Included = true; +} +#pragma warning restore SA1649 // File name should match first type name +#pragma warning restore SA1403 // File may only contain a single namespace + +#define COMPLETEDTASKATTRIBUTE_INCLUDED +#endif diff --git a/src/Microsoft.VisualStudio.Threading.Analyzers.CodeFixes/buildTransitive/Microsoft.VisualStudio.Threading.Analyzers.targets b/src/Microsoft.VisualStudio.Threading.Analyzers.CodeFixes/buildTransitive/Microsoft.VisualStudio.Threading.Analyzers.targets index afce69710..a2a8bcc2c 100644 --- a/src/Microsoft.VisualStudio.Threading.Analyzers.CodeFixes/buildTransitive/Microsoft.VisualStudio.Threading.Analyzers.targets +++ b/src/Microsoft.VisualStudio.Threading.Analyzers.CodeFixes/buildTransitive/Microsoft.VisualStudio.Threading.Analyzers.targets @@ -1,8 +1,11 @@  - + false + + false + diff --git a/src/Microsoft.VisualStudio.Threading.Analyzers/Types.cs b/src/Microsoft.VisualStudio.Threading.Analyzers/Types.cs index f6d24b8db..aa6ac3c6e 100644 --- a/src/Microsoft.VisualStudio.Threading.Analyzers/Types.cs +++ b/src/Microsoft.VisualStudio.Threading.Analyzers/Types.cs @@ -246,4 +246,11 @@ public static class TypeLibTypeAttribute public static readonly ImmutableArray Namespace = Namespaces.SystemRuntimeInteropServices; } + + public static class CompletedTaskAttribute + { + public const string TypeName = "CompletedTaskAttribute"; + + public static readonly ImmutableArray Namespace = Namespaces.MicrosoftVisualStudioThreading; + } } diff --git a/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/VSTHRD003UseJtfRunAsyncAnalyzerTests.cs b/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/VSTHRD003UseJtfRunAsyncAnalyzerTests.cs index ce64483d5..9b86068f5 100644 --- a/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/VSTHRD003UseJtfRunAsyncAnalyzerTests.cs +++ b/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/VSTHRD003UseJtfRunAsyncAnalyzerTests.cs @@ -1453,6 +1453,195 @@ static async Task ListenAndWait() await CSVerify.VerifyAnalyzerAsync(test); } + [Fact] + public async Task DoNotReportWarningWhenAwaitingPropertyWithCompletedTaskAttribute() + { + var test = @" +using System.Threading.Tasks; + +namespace Microsoft.VisualStudio.Threading +{ + [System.AttributeUsage(System.AttributeTargets.Property | System.AttributeTargets.Method | System.AttributeTargets.Field, Inherited = false, AllowMultiple = false)] + internal sealed class CompletedTaskAttribute : System.Attribute + { + } +} + +class Tests +{ + [Microsoft.VisualStudio.Threading.CompletedTask] + private static Task MyCompletedTask { get; } = Task.CompletedTask; + + async Task GetTask() + { + await MyCompletedTask; + } +} +"; + await CSVerify.VerifyAnalyzerAsync(test); + } + + [Fact] + public async Task DoNotReportWarningWhenAwaitingFieldWithCompletedTaskAttribute() + { + var test = @" +using System.Threading.Tasks; + +namespace Microsoft.VisualStudio.Threading +{ + [System.AttributeUsage(System.AttributeTargets.Property | System.AttributeTargets.Method | System.AttributeTargets.Field, Inherited = false, AllowMultiple = false)] + internal sealed class CompletedTaskAttribute : System.Attribute + { + } +} + +class Tests +{ + [Microsoft.VisualStudio.Threading.CompletedTask] + private static readonly Task MyCompletedTask = Task.CompletedTask; + + async Task GetTask() + { + await MyCompletedTask; + } +} +"; + await CSVerify.VerifyAnalyzerAsync(test); + } + + [Fact] + public async Task DoNotReportWarningWhenAwaitingMethodWithCompletedTaskAttribute() + { + var test = @" +using System.Threading.Tasks; + +namespace Microsoft.VisualStudio.Threading +{ + [System.AttributeUsage(System.AttributeTargets.Property | System.AttributeTargets.Method | System.AttributeTargets.Field, Inherited = false, AllowMultiple = false)] + internal sealed class CompletedTaskAttribute : System.Attribute + { + } +} + +class Tests +{ + [Microsoft.VisualStudio.Threading.CompletedTask] + private static Task GetCompletedTask() => Task.CompletedTask; + + async Task TestMethod() + { + await GetCompletedTask(); + } +} +"; + await CSVerify.VerifyAnalyzerAsync(test); + } + + [Fact] + public async Task DoNotReportWarningWhenReturningPropertyWithCompletedTaskAttribute() + { + var test = @" +using System.Threading.Tasks; + +namespace Microsoft.VisualStudio.Threading +{ + [System.AttributeUsage(System.AttributeTargets.Property | System.AttributeTargets.Method | System.AttributeTargets.Field, Inherited = false, AllowMultiple = false)] + internal sealed class CompletedTaskAttribute : System.Attribute + { + } +} + +class Tests +{ + [Microsoft.VisualStudio.Threading.CompletedTask] + private static Task MyCompletedTask { get; } = Task.CompletedTask; + + Task GetTask() => MyCompletedTask; +} +"; + await CSVerify.VerifyAnalyzerAsync(test); + } + + [Fact] + public async Task ReportWarningWhenAwaitingPropertyWithoutCompletedTaskAttribute() + { + var test = @" +using System.Threading.Tasks; + +class Tests +{ + private static Task MyTask { get; } = Task.Run(() => {}); + + async Task GetTask() + { + await [|MyTask|]; + } +} +"; + await CSVerify.VerifyAnalyzerAsync(test); + } + + [Fact] + public async Task DoNotReportWarningWhenAwaitingTaskGenericPropertyWithCompletedTaskAttribute() + { + var test = @" +using System.Threading.Tasks; + +namespace Microsoft.VisualStudio.Threading +{ + [System.AttributeUsage(System.AttributeTargets.Property | System.AttributeTargets.Method | System.AttributeTargets.Field, Inherited = false, AllowMultiple = false)] + internal sealed class CompletedTaskAttribute : System.Attribute + { + } +} + +class Tests +{ + [Microsoft.VisualStudio.Threading.CompletedTask] + private static Task MyCompletedTask { get; } = Task.FromResult(42); + + async Task GetResult() + { + return await MyCompletedTask; + } +} +"; + await CSVerify.VerifyAnalyzerAsync(test); + } + + [Fact] + public async Task DoNotReportWarningWhenAwaitingPropertyWithCompletedTaskAttributeInJtfRun() + { + var test = @" +using System.Threading.Tasks; +using Microsoft.VisualStudio.Threading; + +namespace Microsoft.VisualStudio.Threading +{ + [System.AttributeUsage(System.AttributeTargets.Property | System.AttributeTargets.Method | System.AttributeTargets.Field, Inherited = false, AllowMultiple = false)] + internal sealed class CompletedTaskAttribute : System.Attribute + { + } +} + +class Tests +{ + [Microsoft.VisualStudio.Threading.CompletedTask] + private static Task MyCompletedTask { get; } = Task.CompletedTask; + + void TestMethod() + { + JoinableTaskFactory jtf = null; + jtf.Run(async delegate + { + await MyCompletedTask; + }); + } +} +"; + await CSVerify.VerifyAnalyzerAsync(test); + } + private DiagnosticResult CreateDiagnostic(int line, int column, int length) => CSVerify.Diagnostic().WithSpan(line, column, line, column + length); } From a8640393dc5a5b5700cceb82d18e0dde7ce64555 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 21 Nov 2025 06:10:28 +0000 Subject: [PATCH 3/9] Fix CompletedTaskAttribute preprocessor directive and update documentation Co-authored-by: drewnoakes <350947+drewnoakes@users.noreply.github.com> --- docfx/analyzers/VSTHRD003.md | 26 +++++++++++++++++++ .../AdditionalFiles/CompletedTaskAttribute.cs | 11 ++------ 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/docfx/analyzers/VSTHRD003.md b/docfx/analyzers/VSTHRD003.md index 49d8ad87b..b5e6c04ee 100644 --- a/docfx/analyzers/VSTHRD003.md +++ b/docfx/analyzers/VSTHRD003.md @@ -10,6 +10,32 @@ 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 TrueTask = Task.FromResult(true); + +async Task MyMethodAsync() +{ + await TrueTask; // No warning - TrueTask is marked as a completed task +} +``` + +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, diff --git a/src/Microsoft.VisualStudio.Threading.Analyzers.CodeFixes/buildTransitive/AdditionalFiles/CompletedTaskAttribute.cs b/src/Microsoft.VisualStudio.Threading.Analyzers.CodeFixes/buildTransitive/AdditionalFiles/CompletedTaskAttribute.cs index d10179d45..867aa9d59 100644 --- a/src/Microsoft.VisualStudio.Threading.Analyzers.CodeFixes/buildTransitive/AdditionalFiles/CompletedTaskAttribute.cs +++ b/src/Microsoft.VisualStudio.Threading.Analyzers.CodeFixes/buildTransitive/AdditionalFiles/CompletedTaskAttribute.cs @@ -2,6 +2,7 @@ // 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; @@ -16,18 +17,10 @@ namespace Microsoft.VisualStudio.Threading; /// as awaiting an already-completed task does not pose a risk of deadlock. /// [System.AttributeUsage(System.AttributeTargets.Property | System.AttributeTargets.Method | System.AttributeTargets.Field, Inherited = false, AllowMultiple = false)] -internal sealed class CompletedTaskAttribute : System.Attribute -{ -} - -#pragma warning disable SA1403 // File may only contain a single namespace #pragma warning disable SA1649 // File name should match first type name -internal static class CompletedTaskAttributeDefinition +internal sealed class CompletedTaskAttribute : System.Attribute { - internal const bool Included = true; } #pragma warning restore SA1649 // File name should match first type name -#pragma warning restore SA1403 // File may only contain a single namespace -#define COMPLETEDTASKATTRIBUTE_INCLUDED #endif From d067b662f51da104daaaa8d85bee8d10f2bede9b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 21 Nov 2025 14:03:23 +0000 Subject: [PATCH 4/9] Add assembly-level CompletedTaskAttribute support for external types Co-authored-by: drewnoakes <350947+drewnoakes@users.noreply.github.com> --- docfx/analyzers/VSTHRD003.md | 11 + .../VSTHRD003UseJtfRunAsyncAnalyzer.cs | 79 ++++++- .../AdditionalFiles/CompletedTaskAttribute.cs | 27 ++- .../VSTHRD003UseJtfRunAsyncAnalyzerTests.cs | 210 ++++++++++++++++++ 4 files changed, 319 insertions(+), 8 deletions(-) diff --git a/docfx/analyzers/VSTHRD003.md b/docfx/analyzers/VSTHRD003.md index b5e6c04ee..f25ea6fc2 100644 --- a/docfx/analyzers/VSTHRD003.md +++ b/docfx/analyzers/VSTHRD003.md @@ -26,6 +26,17 @@ async Task MyMethodAsync() } ``` +### 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(...)` diff --git a/src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD003UseJtfRunAsyncAnalyzer.cs b/src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD003UseJtfRunAsyncAnalyzer.cs index b9e55facb..e96e6b8d5 100644 --- a/src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD003UseJtfRunAsyncAnalyzer.cs +++ b/src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD003UseJtfRunAsyncAnalyzer.cs @@ -71,16 +71,42 @@ public override void Initialize(AnalysisContext context) context.RegisterSyntaxNodeAction(Utils.DebuggableWrapper(this.AnalyzeLambdaExpression), SyntaxKind.ParenthesizedLambdaExpression); } - private static bool IsSymbolAlwaysOkToAwait(ISymbol? symbol) + private static bool IsSymbolAlwaysOkToAwait(ISymbol? symbol, Compilation compilation) { - // Check if the symbol has the CompletedTaskAttribute - if (symbol?.GetAttributes().Any(attr => + if (symbol is null) + { + 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)) == true) + attr.AttributeClass.BelongsToNamespace(Types.CompletedTaskAttribute.Namespace))) { return true; } + // 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 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 IFieldSymbol field) { // Allow the TplExtensions.CompletedTask and related fields. @@ -103,6 +129,45 @@ 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 parts = new List(); + + // 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 AnalyzeArrowExpressionClause(SyntaxNodeAnalysisContext context) { var arrowExpressionClause = (ArrowExpressionClauseSyntax)context.Node; @@ -191,7 +256,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) @@ -285,7 +350,7 @@ private void AnalyzeAwaitExpression(SyntaxNodeAnalysisContext context) } ISymbol? definition = declarationSemanticModel.GetSymbolInfo(memberAccessSyntax, cancellationToken).Symbol; - if (IsSymbolAlwaysOkToAwait(definition)) + if (IsSymbolAlwaysOkToAwait(definition, context.Compilation)) { return null; } @@ -297,7 +362,7 @@ private void AnalyzeAwaitExpression(SyntaxNodeAnalysisContext context) break; case IMethodSymbol methodSymbol: // Check if the method itself has the CompletedTaskAttribute - if (IsSymbolAlwaysOkToAwait(methodSymbol)) + if (IsSymbolAlwaysOkToAwait(methodSymbol, context.Compilation)) { return null; } diff --git a/src/Microsoft.VisualStudio.Threading.Analyzers.CodeFixes/buildTransitive/AdditionalFiles/CompletedTaskAttribute.cs b/src/Microsoft.VisualStudio.Threading.Analyzers.CodeFixes/buildTransitive/AdditionalFiles/CompletedTaskAttribute.cs index 867aa9d59..4a2b0bb66 100644 --- a/src/Microsoft.VisualStudio.Threading.Analyzers.CodeFixes/buildTransitive/AdditionalFiles/CompletedTaskAttribute.cs +++ b/src/Microsoft.VisualStudio.Threading.Analyzers.CodeFixes/buildTransitive/AdditionalFiles/CompletedTaskAttribute.cs @@ -11,15 +11,40 @@ namespace Microsoft.VisualStudio.Threading; /// This suppresses VSTHRD003 warnings when awaiting the returned task. /// /// +/// /// 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. +/// +/// +/// This attribute can also be applied at the assembly level to mark members in external types +/// that you don't control: +/// +/// [assembly: CompletedTask(Member = "System.Threading.Tasks.TplExtensions.TrueTask")] +/// +/// /// -[System.AttributeUsage(System.AttributeTargets.Property | System.AttributeTargets.Method | System.AttributeTargets.Field, Inherited = false, AllowMultiple = false)] +[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 { + /// + /// Initializes a new instance of the class. + /// + public CompletedTaskAttribute() + { + } + + /// + /// 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. + /// + /// + /// The format should be: "Namespace.TypeName.MemberName". + /// For example: "System.Threading.Tasks.TplExtensions.TrueTask". + /// + public string? Member { get; set; } } #pragma warning restore SA1649 // File name should match first type name diff --git a/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/VSTHRD003UseJtfRunAsyncAnalyzerTests.cs b/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/VSTHRD003UseJtfRunAsyncAnalyzerTests.cs index 9b86068f5..6cd981fc8 100644 --- a/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/VSTHRD003UseJtfRunAsyncAnalyzerTests.cs +++ b/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/VSTHRD003UseJtfRunAsyncAnalyzerTests.cs @@ -1642,6 +1642,216 @@ void TestMethod() await CSVerify.VerifyAnalyzerAsync(test); } + [Fact] + public async Task DoNotReportWarningWhenAwaitingPropertyMarkedByAssemblyLevelAttribute() + { + var test = @" +using System.Threading.Tasks; + +[assembly: Microsoft.VisualStudio.Threading.CompletedTask(Member = ""ExternalLibrary.ExternalClass.CompletedTaskProperty"")] + +namespace Microsoft.VisualStudio.Threading +{ + [System.AttributeUsage(System.AttributeTargets.Property | System.AttributeTargets.Method | System.AttributeTargets.Field | System.AttributeTargets.Assembly, Inherited = false, AllowMultiple = true)] + internal sealed class CompletedTaskAttribute : System.Attribute + { + public CompletedTaskAttribute() { } + public string? Member { get; set; } + } +} + +namespace ExternalLibrary +{ + public static class ExternalClass + { + public static Task CompletedTaskProperty { get; } = Task.CompletedTask; + } +} + +class Tests +{ + async Task TestMethod() + { + await ExternalLibrary.ExternalClass.CompletedTaskProperty; + } +} +"; + await CSVerify.VerifyAnalyzerAsync(test); + } + + [Fact] + public async Task DoNotReportWarningWhenAwaitingFieldMarkedByAssemblyLevelAttribute() + { + var test = @" +using System.Threading.Tasks; + +[assembly: Microsoft.VisualStudio.Threading.CompletedTask(Member = ""ExternalLibrary.ExternalClass.CompletedTaskField"")] + +namespace Microsoft.VisualStudio.Threading +{ + [System.AttributeUsage(System.AttributeTargets.Property | System.AttributeTargets.Method | System.AttributeTargets.Field | System.AttributeTargets.Assembly, Inherited = false, AllowMultiple = true)] + internal sealed class CompletedTaskAttribute : System.Attribute + { + public CompletedTaskAttribute() { } + public string? Member { get; set; } + } +} + +namespace ExternalLibrary +{ + public static class ExternalClass + { + public static readonly Task CompletedTaskField = Task.FromResult(true); + } +} + +class Tests +{ + async Task TestMethod() + { + await ExternalLibrary.ExternalClass.CompletedTaskField; + } +} +"; + await CSVerify.VerifyAnalyzerAsync(test); + } + + [Fact] + public async Task DoNotReportWarningWhenAwaitingMethodMarkedByAssemblyLevelAttribute() + { + var test = @" +using System.Threading.Tasks; + +[assembly: Microsoft.VisualStudio.Threading.CompletedTask(Member = ""ExternalLibrary.ExternalClass.GetCompletedTask"")] + +namespace Microsoft.VisualStudio.Threading +{ + [System.AttributeUsage(System.AttributeTargets.Property | System.AttributeTargets.Method | System.AttributeTargets.Field | System.AttributeTargets.Assembly, Inherited = false, AllowMultiple = true)] + internal sealed class CompletedTaskAttribute : System.Attribute + { + public CompletedTaskAttribute() { } + public string? Member { get; set; } + } +} + +namespace ExternalLibrary +{ + public static class ExternalClass + { + public static Task GetCompletedTask() => Task.CompletedTask; + } +} + +class Tests +{ + async Task TestMethod() + { + await ExternalLibrary.ExternalClass.GetCompletedTask(); + } +} +"; + await CSVerify.VerifyAnalyzerAsync(test); + } + + [Fact] + public async Task DoNotReportWarningWhenReturningPropertyMarkedByAssemblyLevelAttribute() + { + var test = @" +using System.Threading.Tasks; + +[assembly: Microsoft.VisualStudio.Threading.CompletedTask(Member = ""ExternalLibrary.ExternalClass.CompletedTaskProperty"")] + +namespace Microsoft.VisualStudio.Threading +{ + [System.AttributeUsage(System.AttributeTargets.Property | System.AttributeTargets.Method | System.AttributeTargets.Field | System.AttributeTargets.Assembly, Inherited = false, AllowMultiple = true)] + internal sealed class CompletedTaskAttribute : System.Attribute + { + public CompletedTaskAttribute() { } + public string? Member { get; set; } + } +} + +namespace ExternalLibrary +{ + public static class ExternalClass + { + public static Task CompletedTaskProperty { get; } = Task.CompletedTask; + } +} + +class Tests +{ + Task GetTask() => ExternalLibrary.ExternalClass.CompletedTaskProperty; +} +"; + await CSVerify.VerifyAnalyzerAsync(test); + } + + [Fact] + public async Task ReportWarningWhenAwaitingPropertyNotMarkedByAssemblyLevelAttribute() + { + var test = @" +using System.Threading.Tasks; + +namespace ExternalLibrary +{ + public static class ExternalClass + { + public static Task SomeTaskProperty { get; } = Task.Run(() => {}); + } +} + +class Tests +{ + async Task TestMethod() + { + await [|ExternalLibrary.ExternalClass.SomeTaskProperty|]; + } +} +"; + await CSVerify.VerifyAnalyzerAsync(test); + } + + [Fact] + public async Task DoNotReportWarningWithMultipleAssemblyLevelAttributes() + { + var test = @" +using System.Threading.Tasks; + +[assembly: Microsoft.VisualStudio.Threading.CompletedTask(Member = ""ExternalLibrary.ExternalClass.Task1"")] +[assembly: Microsoft.VisualStudio.Threading.CompletedTask(Member = ""ExternalLibrary.ExternalClass.Task2"")] + +namespace Microsoft.VisualStudio.Threading +{ + [System.AttributeUsage(System.AttributeTargets.Property | System.AttributeTargets.Method | System.AttributeTargets.Field | System.AttributeTargets.Assembly, Inherited = false, AllowMultiple = true)] + internal sealed class CompletedTaskAttribute : System.Attribute + { + public CompletedTaskAttribute() { } + public string? Member { get; set; } + } +} + +namespace ExternalLibrary +{ + public static class ExternalClass + { + public static Task Task1 { get; } = Task.CompletedTask; + public static Task Task2 { get; } = Task.FromResult(true); + } +} + +class Tests +{ + async Task TestMethod() + { + await ExternalLibrary.ExternalClass.Task1; + await ExternalLibrary.ExternalClass.Task2; + } +} +"; + await CSVerify.VerifyAnalyzerAsync(test); + } + private DiagnosticResult CreateDiagnostic(int line, int column, int length) => CSVerify.Diagnostic().WithSpan(line, column, line, column + length); } From a593dc1928ef7e2368147710200f49f6f0baae7c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 3 Dec 2025 00:21:41 +0000 Subject: [PATCH 5/9] Add validation for CompletedTaskAttribute on non-readonly fields and properties with non-private setters Co-authored-by: drewnoakes <350947+drewnoakes@users.noreply.github.com> --- docfx/analyzers/VSTHRD003.md | 4 + .../VSTHRD003UseJtfRunAsyncAnalyzer.cs | 18 +++ .../VSTHRD003UseJtfRunAsyncAnalyzerTests.cs | 140 ++++++++++++++++++ 3 files changed, 162 insertions(+) diff --git a/docfx/analyzers/VSTHRD003.md b/docfx/analyzers/VSTHRD003.md index f25ea6fc2..c40a3d0fc 100644 --- a/docfx/analyzers/VSTHRD003.md +++ b/docfx/analyzers/VSTHRD003.md @@ -26,6 +26,10 @@ async Task MyMethodAsync() } ``` +**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: diff --git a/src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD003UseJtfRunAsyncAnalyzer.cs b/src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD003UseJtfRunAsyncAnalyzer.cs index e96e6b8d5..42458a448 100644 --- a/src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD003UseJtfRunAsyncAnalyzer.cs +++ b/src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD003UseJtfRunAsyncAnalyzer.cs @@ -83,6 +83,24 @@ private static bool IsSymbolAlwaysOkToAwait(ISymbol? symbol, Compilation compila attr.AttributeClass?.Name == Types.CompletedTaskAttribute.TypeName && attr.AttributeClass.BelongsToNamespace(Types.CompletedTaskAttribute.Namespace))) { + // Validate that the attribute is used correctly + if (symbol is IFieldSymbol fieldSymbol) + { + // Fields must be readonly + if (!fieldSymbol.IsReadOnly) + { + return false; + } + } + else if (symbol is IPropertySymbol propertySymbol) + { + // Properties must not have non-private setters + if (propertySymbol.SetMethod is not null && propertySymbol.SetMethod.DeclaredAccessibility != Accessibility.Private) + { + return false; + } + } + return true; } diff --git a/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/VSTHRD003UseJtfRunAsyncAnalyzerTests.cs b/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/VSTHRD003UseJtfRunAsyncAnalyzerTests.cs index 6cd981fc8..eebe26c14 100644 --- a/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/VSTHRD003UseJtfRunAsyncAnalyzerTests.cs +++ b/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/VSTHRD003UseJtfRunAsyncAnalyzerTests.cs @@ -1852,6 +1852,146 @@ async Task TestMethod() await CSVerify.VerifyAnalyzerAsync(test); } + [Fact] + public async Task ReportWarningWhenCompletedTaskAttributeOnNonReadonlyField() + { + var test = @" +using System.Threading.Tasks; + +namespace Microsoft.VisualStudio.Threading +{ + [System.AttributeUsage(System.AttributeTargets.Property | System.AttributeTargets.Method | System.AttributeTargets.Field, Inherited = false, AllowMultiple = false)] + internal sealed class CompletedTaskAttribute : System.Attribute + { + } +} + +class Tests +{ + [Microsoft.VisualStudio.Threading.CompletedTask] + private static Task MyTask = Task.CompletedTask; // Not readonly + + async Task GetTask() + { + await [|MyTask|]; + } +} +"; + await CSVerify.VerifyAnalyzerAsync(test); + } + + [Fact] + public async Task ReportWarningWhenCompletedTaskAttributeOnPropertyWithPublicSetter() + { + var test = @" +using System.Threading.Tasks; + +namespace Microsoft.VisualStudio.Threading +{ + [System.AttributeUsage(System.AttributeTargets.Property | System.AttributeTargets.Method | System.AttributeTargets.Field, Inherited = false, AllowMultiple = false)] + internal sealed class CompletedTaskAttribute : System.Attribute + { + } +} + +class Tests +{ + [Microsoft.VisualStudio.Threading.CompletedTask] + public static Task MyTask { get; set; } = Task.CompletedTask; // Public setter + + async Task GetTask() + { + await [|MyTask|]; + } +} +"; + await CSVerify.VerifyAnalyzerAsync(test); + } + + [Fact] + public async Task ReportWarningWhenCompletedTaskAttributeOnPropertyWithInternalSetter() + { + var test = @" +using System.Threading.Tasks; + +namespace Microsoft.VisualStudio.Threading +{ + [System.AttributeUsage(System.AttributeTargets.Property | System.AttributeTargets.Method | System.AttributeTargets.Field, Inherited = false, AllowMultiple = false)] + internal sealed class CompletedTaskAttribute : System.Attribute + { + } +} + +class Tests +{ + [Microsoft.VisualStudio.Threading.CompletedTask] + public static Task MyTask { get; internal set; } = Task.CompletedTask; // Internal setter + + async Task GetTask() + { + await [|MyTask|]; + } +} +"; + await CSVerify.VerifyAnalyzerAsync(test); + } + + [Fact] + public async Task DoNotReportWarningWhenCompletedTaskAttributeOnPropertyWithPrivateSetter() + { + var test = @" +using System.Threading.Tasks; + +namespace Microsoft.VisualStudio.Threading +{ + [System.AttributeUsage(System.AttributeTargets.Property | System.AttributeTargets.Method | System.AttributeTargets.Field, Inherited = false, AllowMultiple = false)] + internal sealed class CompletedTaskAttribute : System.Attribute + { + } +} + +class Tests +{ + [Microsoft.VisualStudio.Threading.CompletedTask] + public static Task MyTask { get; private set; } = Task.CompletedTask; // Private setter is OK + + async Task GetTask() + { + await MyTask; + } +} +"; + await CSVerify.VerifyAnalyzerAsync(test); + } + + [Fact] + public async Task DoNotReportWarningWhenCompletedTaskAttributeOnPropertyWithGetterOnly() + { + var test = @" +using System.Threading.Tasks; + +namespace Microsoft.VisualStudio.Threading +{ + [System.AttributeUsage(System.AttributeTargets.Property | System.AttributeTargets.Method | System.AttributeTargets.Field, Inherited = false, AllowMultiple = false)] + internal sealed class CompletedTaskAttribute : System.Attribute + { + } +} + +class Tests +{ + [Microsoft.VisualStudio.Threading.CompletedTask] + public static Task MyTask { get; } = Task.CompletedTask; // Getter-only is OK + + async Task GetTask() + { + await MyTask; + } +} +"; + await CSVerify.VerifyAnalyzerAsync(test); + } + private DiagnosticResult CreateDiagnostic(int line, int column, int length) => CSVerify.Diagnostic().WithSpan(line, column, line, column + length); } From 9e2bda887b07b605677404da7fbce8b6820179d3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Dec 2025 07:35:13 +0000 Subject: [PATCH 6/9] Add diagnostic for invalid CompletedTaskAttribute usage and support for init accessors Co-authored-by: drewnoakes <350947+drewnoakes@users.noreply.github.com> --- .../VSTHRD003UseJtfRunAsyncAnalyzer.cs | 86 ++++++++- .../Strings.resx | 6 + .../VSTHRD003UseJtfRunAsyncAnalyzerTests.cs | 173 +++++++++++++++++- 3 files changed, 256 insertions(+), 9 deletions(-) diff --git a/src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD003UseJtfRunAsyncAnalyzer.cs b/src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD003UseJtfRunAsyncAnalyzer.cs index 42458a448..5a2be4600 100644 --- a/src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD003UseJtfRunAsyncAnalyzer.cs +++ b/src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD003UseJtfRunAsyncAnalyzer.cs @@ -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)), @@ -54,7 +63,7 @@ public override ImmutableArray SupportedDiagnostics { get { - return ImmutableArray.Create(Descriptor); + return ImmutableArray.Create(InvalidAttributeUseDescriptor, Descriptor); } } @@ -69,6 +78,7 @@ 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, Compilation compilation) @@ -95,9 +105,22 @@ private static bool IsSymbolAlwaysOkToAwait(ISymbol? symbol, Compilation compila else if (symbol is IPropertySymbol propertySymbol) { // Properties must not have non-private setters - if (propertySymbol.SetMethod is not null && propertySymbol.SetMethod.DeclaredAccessibility != Accessibility.Private) + // Init accessors are only allowed if the property itself is private + if (propertySymbol.SetMethod is not null) { - return false; + 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; + } } } @@ -186,6 +209,63 @@ private static string GetFullyQualifiedName(ISymbol symbol) 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 = "Fields must be readonly."; + } + } + 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 = "Properties with init accessors must be private."; + } + } + else if (propertySymbol.SetMethod.DeclaredAccessibility != Accessibility.Private) + { + // Non-private setters are not allowed + errorMessage = "Properties must not have non-private setters."; + } + } + } + + // 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; diff --git a/src/Microsoft.VisualStudio.Threading.Analyzers/Strings.resx b/src/Microsoft.VisualStudio.Threading.Analyzers/Strings.resx index 97e248e4d..7df2dd79e 100644 --- a/src/Microsoft.VisualStudio.Threading.Analyzers/Strings.resx +++ b/src/Microsoft.VisualStudio.Threading.Analyzers/Strings.resx @@ -353,4 +353,10 @@ Start the work within this context, or use JoinableTaskFactory.RunAsync to start Use 'JoinableTaskContext.CreateNoOpContext' instead. + + Invalid use of CompletedTaskAttribute + + + CompletedTaskAttribute can only be applied to readonly fields, properties without non-private setters, or methods. {0} + \ No newline at end of file diff --git a/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/VSTHRD003UseJtfRunAsyncAnalyzerTests.cs b/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/VSTHRD003UseJtfRunAsyncAnalyzerTests.cs index eebe26c14..451e178cb 100644 --- a/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/VSTHRD003UseJtfRunAsyncAnalyzerTests.cs +++ b/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/VSTHRD003UseJtfRunAsyncAnalyzerTests.cs @@ -1868,7 +1868,7 @@ internal sealed class CompletedTaskAttribute : System.Attribute class Tests { - [Microsoft.VisualStudio.Threading.CompletedTask] + [{|#0:Microsoft.VisualStudio.Threading.CompletedTask|}] private static Task MyTask = Task.CompletedTask; // Not readonly async Task GetTask() @@ -1877,7 +1877,10 @@ async Task GetTask() } } "; - await CSVerify.VerifyAnalyzerAsync(test); + DiagnosticResult expected = new DiagnosticResult(Microsoft.VisualStudio.Threading.Analyzers.VSTHRD003UseJtfRunAsyncAnalyzer.InvalidAttributeUseDescriptor) + .WithLocation(0) + .WithArguments("Fields must be readonly."); + await CSVerify.VerifyAnalyzerAsync(test, expected); } [Fact] @@ -1896,7 +1899,7 @@ internal sealed class CompletedTaskAttribute : System.Attribute class Tests { - [Microsoft.VisualStudio.Threading.CompletedTask] + [{|#0:Microsoft.VisualStudio.Threading.CompletedTask|}] public static Task MyTask { get; set; } = Task.CompletedTask; // Public setter async Task GetTask() @@ -1905,7 +1908,10 @@ async Task GetTask() } } "; - await CSVerify.VerifyAnalyzerAsync(test); + DiagnosticResult expected = new DiagnosticResult(Microsoft.VisualStudio.Threading.Analyzers.VSTHRD003UseJtfRunAsyncAnalyzer.InvalidAttributeUseDescriptor) + .WithLocation(0) + .WithArguments("Properties must not have non-private setters."); + await CSVerify.VerifyAnalyzerAsync(test, expected); } [Fact] @@ -1924,7 +1930,7 @@ internal sealed class CompletedTaskAttribute : System.Attribute class Tests { - [Microsoft.VisualStudio.Threading.CompletedTask] + [{|#0:Microsoft.VisualStudio.Threading.CompletedTask|}] public static Task MyTask { get; internal set; } = Task.CompletedTask; // Internal setter async Task GetTask() @@ -1933,7 +1939,10 @@ async Task GetTask() } } "; - await CSVerify.VerifyAnalyzerAsync(test); + DiagnosticResult expected = new DiagnosticResult(Microsoft.VisualStudio.Threading.Analyzers.VSTHRD003UseJtfRunAsyncAnalyzer.InvalidAttributeUseDescriptor) + .WithLocation(0) + .WithArguments("Properties must not have non-private setters."); + await CSVerify.VerifyAnalyzerAsync(test, expected); } [Fact] @@ -1992,6 +2001,158 @@ async Task GetTask() await CSVerify.VerifyAnalyzerAsync(test); } + [Fact] + public async Task ReportDiagnosticWhenCompletedTaskAttributeOnPropertyWithPublicInit() + { + var test = @" +using System.Threading.Tasks; + +namespace Microsoft.VisualStudio.Threading +{ + [System.AttributeUsage(System.AttributeTargets.Property | System.AttributeTargets.Method | System.AttributeTargets.Field, Inherited = false, AllowMultiple = false)] + internal sealed class CompletedTaskAttribute : System.Attribute + { + } +} + +class Tests +{ + [{|#0:Microsoft.VisualStudio.Threading.CompletedTask|}] + public static Task MyTask { get; init; } = Task.CompletedTask; // Public init + + async Task GetTask() + { + await [|MyTask|]; + } +} +"; + DiagnosticResult expected = new DiagnosticResult(Microsoft.VisualStudio.Threading.Analyzers.VSTHRD003UseJtfRunAsyncAnalyzer.InvalidAttributeUseDescriptor) + .WithLocation(0) + .WithArguments("Properties with init accessors must be private."); + await CSVerify.VerifyAnalyzerAsync(test, expected); + } + + [Fact] + public async Task DoNotReportWarningWhenCompletedTaskAttributeOnPrivatePropertyWithInit() + { + var test = @" +using System.Threading.Tasks; + +namespace Microsoft.VisualStudio.Threading +{ + [System.AttributeUsage(System.AttributeTargets.Property | System.AttributeTargets.Method | System.AttributeTargets.Field, Inherited = false, AllowMultiple = false)] + internal sealed class CompletedTaskAttribute : System.Attribute + { + } +} + +class Tests +{ + [Microsoft.VisualStudio.Threading.CompletedTask] + private static Task MyTask { get; init; } = Task.CompletedTask; // Private init is OK + + async Task GetTask() + { + await MyTask; + } +} +"; + await CSVerify.VerifyAnalyzerAsync(test); + } + + [Fact] + public async Task ReportDiagnosticWhenCompletedTaskAttributeOnNonReadonlyFieldWithDiagnosticOnAttribute() + { + var test = @" +using System.Threading.Tasks; + +namespace Microsoft.VisualStudio.Threading +{ + [System.AttributeUsage(System.AttributeTargets.Property | System.AttributeTargets.Method | System.AttributeTargets.Field, Inherited = false, AllowMultiple = false)] + internal sealed class CompletedTaskAttribute : System.Attribute + { + } +} + +class Tests +{ + [{|#0:Microsoft.VisualStudio.Threading.CompletedTask|}] + private static Task MyTask = Task.CompletedTask; // Not readonly + + async Task GetTask() + { + await [|MyTask|]; + } +} +"; + DiagnosticResult expected = new DiagnosticResult(Microsoft.VisualStudio.Threading.Analyzers.VSTHRD003UseJtfRunAsyncAnalyzer.InvalidAttributeUseDescriptor) + .WithLocation(0) + .WithArguments("Fields must be readonly."); + await CSVerify.VerifyAnalyzerAsync(test, expected); + } + + [Fact] + public async Task ReportDiagnosticWhenCompletedTaskAttributeOnPropertyWithPublicSetterWithDiagnosticOnAttribute() + { + var test = @" +using System.Threading.Tasks; + +namespace Microsoft.VisualStudio.Threading +{ + [System.AttributeUsage(System.AttributeTargets.Property | System.AttributeTargets.Method | System.AttributeTargets.Field, Inherited = false, AllowMultiple = false)] + internal sealed class CompletedTaskAttribute : System.Attribute + { + } +} + +class Tests +{ + [{|#0:Microsoft.VisualStudio.Threading.CompletedTask|}] + public static Task MyTask { get; set; } = Task.CompletedTask; // Public setter + + async Task GetTask() + { + await [|MyTask|]; + } +} +"; + DiagnosticResult expected = new DiagnosticResult(Microsoft.VisualStudio.Threading.Analyzers.VSTHRD003UseJtfRunAsyncAnalyzer.InvalidAttributeUseDescriptor) + .WithLocation(0) + .WithArguments("Properties must not have non-private setters."); + await CSVerify.VerifyAnalyzerAsync(test, expected); + } + + [Fact] + public async Task ReportDiagnosticWhenCompletedTaskAttributeOnPropertyWithInternalSetterWithDiagnosticOnAttribute() + { + var test = @" +using System.Threading.Tasks; + +namespace Microsoft.VisualStudio.Threading +{ + [System.AttributeUsage(System.AttributeTargets.Property | System.AttributeTargets.Method | System.AttributeTargets.Field, Inherited = false, AllowMultiple = false)] + internal sealed class CompletedTaskAttribute : System.Attribute + { + } +} + +class Tests +{ + [{|#0:Microsoft.VisualStudio.Threading.CompletedTask|}] + public static Task MyTask { get; internal set; } = Task.CompletedTask; // Internal setter + + async Task GetTask() + { + await [|MyTask|]; + } +} +"; + DiagnosticResult expected = new DiagnosticResult(Microsoft.VisualStudio.Threading.Analyzers.VSTHRD003UseJtfRunAsyncAnalyzer.InvalidAttributeUseDescriptor) + .WithLocation(0) + .WithArguments("Properties must not have non-private setters."); + await CSVerify.VerifyAnalyzerAsync(test, expected); + } + private DiagnosticResult CreateDiagnostic(int line, int column, int length) => CSVerify.Diagnostic().WithSpan(line, column, line, column + length); } From 83bcc445e57aef800a443dbc44b809b5458a6e53 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Dec 2025 12:12:38 +0000 Subject: [PATCH 7/9] Localize error messages for CompletedTaskAttribute validation Co-authored-by: drewnoakes <350947+drewnoakes@users.noreply.github.com> --- .../VSTHRD003UseJtfRunAsyncAnalyzer.cs | 6 +++--- .../Strings.resx | 12 ++++++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD003UseJtfRunAsyncAnalyzer.cs b/src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD003UseJtfRunAsyncAnalyzer.cs index 5a2be4600..541bd0095 100644 --- a/src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD003UseJtfRunAsyncAnalyzer.cs +++ b/src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD003UseJtfRunAsyncAnalyzer.cs @@ -230,7 +230,7 @@ private void AnalyzeSymbolForInvalidAttributeUse(SymbolAnalysisContext context) // Fields must be readonly if (!fieldSymbol.IsReadOnly) { - errorMessage = "Fields must be readonly."; + errorMessage = Strings.VSTHRD003InvalidAttributeUse_FieldNotReadonly; } } else if (symbol is IPropertySymbol propertySymbol) @@ -243,13 +243,13 @@ private void AnalyzeSymbolForInvalidAttributeUse(SymbolAnalysisContext context) { if (propertySymbol.DeclaredAccessibility != Accessibility.Private) { - errorMessage = "Properties with init accessors must be private."; + errorMessage = Strings.VSTHRD003InvalidAttributeUse_PropertyWithNonPrivateInit; } } else if (propertySymbol.SetMethod.DeclaredAccessibility != Accessibility.Private) { // Non-private setters are not allowed - errorMessage = "Properties must not have non-private setters."; + errorMessage = Strings.VSTHRD003InvalidAttributeUse_PropertyWithNonPrivateSetter; } } } diff --git a/src/Microsoft.VisualStudio.Threading.Analyzers/Strings.resx b/src/Microsoft.VisualStudio.Threading.Analyzers/Strings.resx index 7df2dd79e..0eb54f629 100644 --- a/src/Microsoft.VisualStudio.Threading.Analyzers/Strings.resx +++ b/src/Microsoft.VisualStudio.Threading.Analyzers/Strings.resx @@ -359,4 +359,16 @@ Start the work within this context, or use JoinableTaskFactory.RunAsync to start CompletedTaskAttribute can only be applied to readonly fields, properties without non-private setters, or methods. {0} + + Fields must be readonly. + 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. + + + Properties must not have non-private setters. + 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. + + + Properties with init accessors must be private. + Error message shown when the CompletedTaskAttribute is applied to a property with a public, internal, or protected init accessor. Init accessors allow setting the property during object initialization, so the property itself must be private when using this attribute to ensure the task value cannot be changed from outside the class. + \ No newline at end of file From 1b80964400a02e15fca4801e166237020d77c45f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Dec 2025 12:17:08 +0000 Subject: [PATCH 8/9] Clarify translator comment for init accessor validation message Co-authored-by: drewnoakes <350947+drewnoakes@users.noreply.github.com> --- src/Microsoft.VisualStudio.Threading.Analyzers/Strings.resx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Microsoft.VisualStudio.Threading.Analyzers/Strings.resx b/src/Microsoft.VisualStudio.Threading.Analyzers/Strings.resx index 0eb54f629..a21467e21 100644 --- a/src/Microsoft.VisualStudio.Threading.Analyzers/Strings.resx +++ b/src/Microsoft.VisualStudio.Threading.Analyzers/Strings.resx @@ -369,6 +369,6 @@ Start the work within this context, or use JoinableTaskFactory.RunAsync to start Properties with init accessors must be private. - Error message shown when the CompletedTaskAttribute is applied to a property with a public, internal, or protected init accessor. Init accessors allow setting the property during object initialization, so the property itself must be private when using this attribute to ensure the task value cannot be changed from outside the class. + 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. \ No newline at end of file From 37e1e3dd6d72d77ca05057dd232fc7b6f8d2ea4b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Dec 2025 10:30:13 +0000 Subject: [PATCH 9/9] Add CompletedTaskAttribute to TplExtensions and remove special-case code Co-authored-by: drewnoakes <350947+drewnoakes@users.noreply.github.com> --- .../VSTHRD003UseJtfRunAsyncAnalyzer.cs | 11 +---- .../CompletedTaskAttribute.cs | 46 +++++++++++++++++++ .../TplExtensions.cs | 4 ++ 3 files changed, 51 insertions(+), 10 deletions(-) create mode 100644 src/Microsoft.VisualStudio.Threading/CompletedTaskAttribute.cs diff --git a/src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD003UseJtfRunAsyncAnalyzer.cs b/src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD003UseJtfRunAsyncAnalyzer.cs index 541bd0095..4c4051ae6 100644 --- a/src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD003UseJtfRunAsyncAnalyzer.cs +++ b/src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD003UseJtfRunAsyncAnalyzer.cs @@ -148,16 +148,7 @@ private static bool IsSymbolAlwaysOkToAwait(ISymbol? symbol, Compilation compila } } - if (symbol is IFieldSymbol field) - { - // 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 true; - } - } - else if (symbol is IPropertySymbol property) + if (symbol is IPropertySymbol property) { // Explicitly allow Task.CompletedTask if (property.ContainingType.Name == Types.Task.TypeName && property.BelongsToNamespace(Types.Task.Namespace) && diff --git a/src/Microsoft.VisualStudio.Threading/CompletedTaskAttribute.cs b/src/Microsoft.VisualStudio.Threading/CompletedTaskAttribute.cs new file mode 100644 index 000000000..1952e33e2 --- /dev/null +++ b/src/Microsoft.VisualStudio.Threading/CompletedTaskAttribute.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; + +namespace Microsoft.VisualStudio.Threading; + +/// +/// Indicates that a property, method, or field returns a task that is already completed. +/// This suppresses VSTHRD003 warnings when awaiting the returned task. +/// +/// +/// +/// 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. +/// +/// +/// This attribute can also be applied at the assembly level to mark members in external types +/// that you don't control: +/// +/// [assembly: CompletedTask(Member = "ExternalLibrary.ExternalClass.CompletedTaskProperty")] +/// +/// +/// +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Method | AttributeTargets.Field | AttributeTargets.Assembly, Inherited = false, AllowMultiple = true)] +public sealed class CompletedTaskAttribute : Attribute +{ + /// + /// Initializes a new instance of the class. + /// + public CompletedTaskAttribute() + { + } + + /// + /// 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. + /// + /// + /// The format should be: "Namespace.TypeName.MemberName". + /// For example: "ExternalLibrary.ExternalClass.CompletedTaskProperty". + /// + public string? Member { get; set; } +} diff --git a/src/Microsoft.VisualStudio.Threading/TplExtensions.cs b/src/Microsoft.VisualStudio.Threading/TplExtensions.cs index 9fdb6aa76..188f689c5 100644 --- a/src/Microsoft.VisualStudio.Threading/TplExtensions.cs +++ b/src/Microsoft.VisualStudio.Threading/TplExtensions.cs @@ -20,22 +20,26 @@ public static partial class TplExtensions /// A singleton completed task. /// [Obsolete("Use Task.CompletedTask instead.")] + [CompletedTask] public static readonly Task CompletedTask = Task.FromResult(default(EmptyStruct)); /// /// A task that is already canceled. /// [Obsolete("Use Task.FromCanceled instead.")] + [CompletedTask] public static readonly Task CanceledTask = Task.FromCanceled(new CancellationToken(canceled: true)); /// /// A completed task with a result. /// + [CompletedTask] public static readonly Task TrueTask = Task.FromResult(true); /// /// A completed task with a result. /// + [CompletedTask] public static readonly Task FalseTask = Task.FromResult(false); ///