From 1e22668c77a576d392645dfa942a689298aacb14 Mon Sep 17 00:00:00 2001 From: Andrew Arnott Date: Thu, 20 Aug 2026 10:05:55 -0600 Subject: [PATCH 1/7] Fix VSTHRD104 false positives Recognize async alternatives exposed by extension method receivers and suppress Task.Result diagnostics when IsCompletedSuccessfully guards the same task. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../VSTHRD104OfferAsyncOptionAnalyzer.cs | 49 +++++++++++++ .../Utils.cs | 16 ++++- .../VSTHRD104OfferAsyncOptionAnalyzerTests.cs | 72 +++++++++++++++++++ 3 files changed, 135 insertions(+), 2 deletions(-) diff --git a/src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD104OfferAsyncOptionAnalyzer.cs b/src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD104OfferAsyncOptionAnalyzer.cs index c89bf1dab..4332cf11d 100644 --- a/src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD104OfferAsyncOptionAnalyzer.cs +++ b/src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD104OfferAsyncOptionAnalyzer.cs @@ -98,6 +98,11 @@ private void InspectMemberAccess(SyntaxNodeAnalysisContext context, MemberAccess { if (item.Method.IsMatch(invokedMember)) { + if (invokedMember is IPropertySymbol { Name: nameof(Task.Result) } && this.IsGuardedBySuccessfulCompletion(context, memberAccessSyntax)) + { + return; + } + Location? location = memberAccessSyntax.Name.GetLocation(); context.ReportDiagnostic(Diagnostic.Create(Descriptor, location)); this.diagnosticReported = true; @@ -105,5 +110,49 @@ private void InspectMemberAccess(SyntaxNodeAnalysisContext context, MemberAccess } } } + + private bool IsGuardedBySuccessfulCompletion(SyntaxNodeAnalysisContext context, MemberAccessExpressionSyntax resultAccess) + { + ISymbol? taskSymbol = context.SemanticModel.GetSymbolInfo(resultAccess.Expression, context.CancellationToken).Symbol; + if (taskSymbol is null) + { + return false; + } + + foreach (IfStatementSyntax ifStatement in resultAccess.Ancestors().OfType()) + { + if (ifStatement.Statement.Span.Contains(resultAccess.Span) && ConditionProvesSuccessfulCompletion(ifStatement.Condition, taskSymbol)) + { + return true; + } + } + + return false; + + bool ConditionProvesSuccessfulCompletion(ExpressionSyntax condition, ISymbol expectedTaskSymbol) + { + while (condition is ParenthesizedExpressionSyntax parenthesized) + { + condition = parenthesized.Expression; + } + + if (condition is BinaryExpressionSyntax { RawKind: (int)SyntaxKind.LogicalAndExpression } logicalAnd) + { + return ConditionProvesSuccessfulCompletion(logicalAnd.Left, expectedTaskSymbol) + || ConditionProvesSuccessfulCompletion(logicalAnd.Right, expectedTaskSymbol); + } + + if (condition is not MemberAccessExpressionSyntax { Name.Identifier.ValueText: "IsCompletedSuccessfully" } completionAccess) + { + return false; + } + + ISymbol? completionProperty = context.SemanticModel.GetSymbolInfo(completionAccess, context.CancellationToken).Symbol; + ISymbol? completedTaskSymbol = context.SemanticModel.GetSymbolInfo(completionAccess.Expression, context.CancellationToken).Symbol; + return completionProperty is IPropertySymbol { ContainingType: { } containingType } + && Utils.IsTask(containingType) + && SymbolEqualityComparer.Default.Equals(expectedTaskSymbol, completedTaskSymbol); + } + } } } diff --git a/src/Microsoft.VisualStudio.Threading.Analyzers/Utils.cs b/src/Microsoft.VisualStudio.Threading.Analyzers/Utils.cs index 9a2c376f4..e72bc80b7 100644 --- a/src/Microsoft.VisualStudio.Threading.Analyzers/Utils.cs +++ b/src/Microsoft.VisualStudio.Threading.Analyzers/Utils.cs @@ -324,8 +324,20 @@ public static bool IsLazyOfT([NotNullWhen(true)] INamedTypeSymbol? constructedTy public static bool HasAsyncAlternative(this IMethodSymbol methodSymbol, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - return methodSymbol.ContainingType.GetMembers(methodSymbol.Name + VSTHRD200UseAsyncNamingConventionAnalyzer.MandatoryAsyncSuffix) - .Any(alt => IsXAtLeastAsPublicAsY(alt, methodSymbol)); + string asyncMethodName = methodSymbol.Name + VSTHRD200UseAsyncNamingConventionAnalyzer.MandatoryAsyncSuffix; + if (methodSymbol.ContainingType.GetMembers(asyncMethodName).Any(alt => IsXAtLeastAsPublicAsY(alt, methodSymbol))) + { + return true; + } + + if (methodSymbol.IsExtensionMethod && methodSymbol.Parameters[0].Type is INamedTypeSymbol receiverType) + { + return receiverType.GetMembers(asyncMethodName) + .Concat(receiverType.AllInterfaces.SelectMany(iface => iface.GetMembers(asyncMethodName))) + .Any(alt => IsXAtLeastAsPublicAsY(alt, methodSymbol)); + } + + return false; } public static bool IsXAtLeastAsPublicAsY(ISymbol x, ISymbol y) diff --git a/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/VSTHRD104OfferAsyncOptionAnalyzerTests.cs b/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/VSTHRD104OfferAsyncOptionAnalyzerTests.cs index 8b0acc4eb..975bae7e9 100644 --- a/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/VSTHRD104OfferAsyncOptionAnalyzerTests.cs +++ b/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/VSTHRD104OfferAsyncOptionAnalyzerTests.cs @@ -98,4 +98,76 @@ internal async Task FooAsync() { DiagnosticResult expected = CSVerify.Diagnostic().WithSpan(9, 13, 9, 16); await CSVerify.VerifyAnalyzerAsync(test, expected); } + + [Fact] + public async Task JTFRunFromPublicExtensionMethodWithAsyncInstanceMethod_GeneratesNoWarning() + { + var test = @" +using System.Threading.Tasks; +using Microsoft.VisualStudio.Threading; + +public interface IService { + Task ClearAsync(string category); +} + +public static class ServiceExtensions { + private static readonly JoinableTaskFactory Jtf = new JoinableTaskFactory(new JoinableTaskContext()); + + public static void Clear(this IService service, string category) + => Jtf.Run(() => service.ClearAsync(category)); +} +"; + + await CSVerify.VerifyAnalyzerAsync(test); + } + + [Fact] + public async Task TaskResultGuardedByIsCompletedSuccessfully_GeneratesNoWarning() + { + var test = @" +using System.Threading.Tasks; + +public class Test { + public int GetResult(Task task) { + if (task.IsCompletedSuccessfully) { + return task.Result; + } + + return 0; + } +} +"; + + await new CSVerify.Test + { + TestCode = test, + ReferenceAssemblies = Microsoft.CodeAnalysis.Testing.ReferenceAssemblies.Net.Net80, + }.RunAsync(); + } + + [Fact] + public async Task TaskResultGuardedByOtherTaskCompletion_GeneratesWarning() + { + var test = @" +using System.Threading.Tasks; + +public class Test { + public int GetResult(Task task, Task otherTask) { + if (otherTask.IsCompletedSuccessfully) { + return task.Result; + } + + return 0; + } +} +"; + + var analyzerTest = new CSVerify.Test + { + TestCode = test, + ReferenceAssemblies = Microsoft.CodeAnalysis.Testing.ReferenceAssemblies.Net.Net80, + }; + analyzerTest.ExpectedDiagnostics.Add(CSVerify.Diagnostic().WithSpan(7, 25, 7, 31)); + await analyzerTest.RunAsync(); + } } From 42e14766002162fdd736d00ab8ad11687062c1ed Mon Sep 17 00:00:00 2001 From: Andrew Arnott Date: Thu, 20 Aug 2026 10:16:19 -0600 Subject: [PATCH 2/7] Address VSTHRD104 review feedback Include inherited async alternatives and limit guarded Result suppression to stable Task references. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../VSTHRD104OfferAsyncOptionAnalyzer.cs | 6 +- .../Utils.cs | 11 ++- .../VSTHRD104OfferAsyncOptionAnalyzerTests.cs | 79 +++++++++++++++++++ 3 files changed, 91 insertions(+), 5 deletions(-) diff --git a/src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD104OfferAsyncOptionAnalyzer.cs b/src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD104OfferAsyncOptionAnalyzer.cs index 4332cf11d..89faeb693 100644 --- a/src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD104OfferAsyncOptionAnalyzer.cs +++ b/src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD104OfferAsyncOptionAnalyzer.cs @@ -98,7 +98,9 @@ private void InspectMemberAccess(SyntaxNodeAnalysisContext context, MemberAccess { if (item.Method.IsMatch(invokedMember)) { - if (invokedMember is IPropertySymbol { Name: nameof(Task.Result) } && this.IsGuardedBySuccessfulCompletion(context, memberAccessSyntax)) + if (invokedMember is IPropertySymbol { Name: nameof(Task.Result), ContainingType: { } containingType } + && Utils.IsTask(containingType) + && this.IsGuardedBySuccessfulCompletion(context, memberAccessSyntax)) { return; } @@ -114,7 +116,7 @@ private void InspectMemberAccess(SyntaxNodeAnalysisContext context, MemberAccess private bool IsGuardedBySuccessfulCompletion(SyntaxNodeAnalysisContext context, MemberAccessExpressionSyntax resultAccess) { ISymbol? taskSymbol = context.SemanticModel.GetSymbolInfo(resultAccess.Expression, context.CancellationToken).Symbol; - if (taskSymbol is null) + if (taskSymbol is not ILocalSymbol and not IParameterSymbol) { return false; } diff --git a/src/Microsoft.VisualStudio.Threading.Analyzers/Utils.cs b/src/Microsoft.VisualStudio.Threading.Analyzers/Utils.cs index e72bc80b7..3b48c2862 100644 --- a/src/Microsoft.VisualStudio.Threading.Analyzers/Utils.cs +++ b/src/Microsoft.VisualStudio.Threading.Analyzers/Utils.cs @@ -330,10 +330,15 @@ public static bool HasAsyncAlternative(this IMethodSymbol methodSymbol, Cancella return true; } - if (methodSymbol.IsExtensionMethod && methodSymbol.Parameters[0].Type is INamedTypeSymbol receiverType) + if (methodSymbol.IsExtensionMethod && !methodSymbol.Parameters.IsDefaultOrEmpty && methodSymbol.Parameters[0].Type is INamedTypeSymbol receiverType) { - return receiverType.GetMembers(asyncMethodName) - .Concat(receiverType.AllInterfaces.SelectMany(iface => iface.GetMembers(asyncMethodName))) + IEnumerable receiverMembers = receiverType.AllInterfaces.SelectMany(iface => iface.GetMembers(asyncMethodName)); + for (INamedTypeSymbol? currentType = receiverType; currentType is not null; currentType = currentType.BaseType) + { + receiverMembers = receiverMembers.Concat(currentType.GetMembers(asyncMethodName)); + } + + return receiverMembers .Any(alt => IsXAtLeastAsPublicAsY(alt, methodSymbol)); } diff --git a/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/VSTHRD104OfferAsyncOptionAnalyzerTests.cs b/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/VSTHRD104OfferAsyncOptionAnalyzerTests.cs index 975bae7e9..bf55e19c7 100644 --- a/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/VSTHRD104OfferAsyncOptionAnalyzerTests.cs +++ b/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/VSTHRD104OfferAsyncOptionAnalyzerTests.cs @@ -121,6 +121,31 @@ public static void Clear(this IService service, string category) await CSVerify.VerifyAnalyzerAsync(test); } + [Fact] + public async Task JTFRunFromPublicExtensionMethodWithInheritedAsyncInstanceMethod_GeneratesNoWarning() + { + var test = @" +using System.Threading.Tasks; +using Microsoft.VisualStudio.Threading; + +public class ServiceBase { + public Task ClearAsync(string category) => Task.CompletedTask; +} + +public class Service : ServiceBase { +} + +public static class ServiceExtensions { + private static readonly JoinableTaskFactory Jtf = new JoinableTaskFactory(new JoinableTaskContext()); + + public static void Clear(this Service service, string category) + => Jtf.Run(() => service.ClearAsync(category)); +} +"; + + await CSVerify.VerifyAnalyzerAsync(test); + } + [Fact] public async Task TaskResultGuardedByIsCompletedSuccessfully_GeneratesNoWarning() { @@ -170,4 +195,58 @@ public int GetResult(Task task, Task otherTask) { analyzerTest.ExpectedDiagnostics.Add(CSVerify.Diagnostic().WithSpan(7, 25, 7, 31)); await analyzerTest.RunAsync(); } + + [Fact] + public async Task ValueTaskResultGuardedByIsCompletedSuccessfully_GeneratesWarning() + { + var test = @" +using System.Threading.Tasks; + +public class Test { + public int GetResult(ValueTask task) { + if (task.IsCompletedSuccessfully) { + return task.Result; + } + + return 0; + } +} +"; + + var analyzerTest = new CSVerify.Test + { + TestCode = test, + ReferenceAssemblies = Microsoft.CodeAnalysis.Testing.ReferenceAssemblies.Net.Net80, + }; + analyzerTest.ExpectedDiagnostics.Add(CSVerify.Diagnostic().WithSpan(7, 25, 7, 31)); + await analyzerTest.RunAsync(); + } + + [Fact] + public async Task TaskPropertyResultGuardedByIsCompletedSuccessfully_GeneratesWarning() + { + var test = @" +using System.Threading.Tasks; + +public class Test { + private Task TaskProperty => Task.FromResult(1); + + public int GetResult() { + if (TaskProperty.IsCompletedSuccessfully) { + return TaskProperty.Result; + } + + return 0; + } +} +"; + + var analyzerTest = new CSVerify.Test + { + TestCode = test, + ReferenceAssemblies = Microsoft.CodeAnalysis.Testing.ReferenceAssemblies.Net.Net80, + }; + analyzerTest.ExpectedDiagnostics.Add(CSVerify.Diagnostic().WithSpan(9, 33, 9, 39)); + await analyzerTest.RunAsync(); + } } From f54b6cefd8d9339cc614cd737474365887279c27 Mon Sep 17 00:00:00 2001 From: Andrew Arnott Date: Thu, 20 Aug 2026 10:22:52 -0600 Subject: [PATCH 3/7] Keep completion guards in executable scope Stop VSTHRD104 completion-guard analysis at local function boundaries so deferred execution is not treated as guarded. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../VSTHRD104OfferAsyncOptionAnalyzer.cs | 4 ++- .../VSTHRD104OfferAsyncOptionAnalyzerTests.cs | 27 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD104OfferAsyncOptionAnalyzer.cs b/src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD104OfferAsyncOptionAnalyzer.cs index 89faeb693..c573db924 100644 --- a/src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD104OfferAsyncOptionAnalyzer.cs +++ b/src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD104OfferAsyncOptionAnalyzer.cs @@ -121,7 +121,9 @@ private bool IsGuardedBySuccessfulCompletion(SyntaxNodeAnalysisContext context, return false; } - foreach (IfStatementSyntax ifStatement in resultAccess.Ancestors().OfType()) + foreach (IfStatementSyntax ifStatement in resultAccess.Ancestors() + .TakeWhile(ancestor => ancestor is not LocalFunctionStatementSyntax) + .OfType()) { if (ifStatement.Statement.Span.Contains(resultAccess.Span) && ConditionProvesSuccessfulCompletion(ifStatement.Condition, taskSymbol)) { diff --git a/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/VSTHRD104OfferAsyncOptionAnalyzerTests.cs b/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/VSTHRD104OfferAsyncOptionAnalyzerTests.cs index bf55e19c7..95b694d0f 100644 --- a/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/VSTHRD104OfferAsyncOptionAnalyzerTests.cs +++ b/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/VSTHRD104OfferAsyncOptionAnalyzerTests.cs @@ -249,4 +249,31 @@ public int GetResult() { analyzerTest.ExpectedDiagnostics.Add(CSVerify.Diagnostic().WithSpan(9, 33, 9, 39)); await analyzerTest.RunAsync(); } + + [Fact] + public async Task TaskResultInLocalFunctionWithOuterCompletionGuard_GeneratesWarning() + { + var test = @" +using System.Threading.Tasks; + +public class Test { + public int GetResult(Task task) { + if (task.IsCompletedSuccessfully) { + int LocalFunction() => task.Result; + return LocalFunction(); + } + + return 0; + } +} +"; + + var analyzerTest = new CSVerify.Test + { + TestCode = test, + ReferenceAssemblies = Microsoft.CodeAnalysis.Testing.ReferenceAssemblies.Net.Net80, + }; + analyzerTest.ExpectedDiagnostics.Add(CSVerify.Diagnostic().WithSpan(7, 41, 7, 47)); + await analyzerTest.RunAsync(); + } } From cc51e5d8256b14b9e50eb38fb5c5d8f83e71f4d2 Mon Sep 17 00:00:00 2001 From: Andrew Arnott Date: Thu, 20 Aug 2026 23:24:52 -0600 Subject: [PATCH 4/7] Preserve VSTHRD104 completion proof safety Invalidate Task completion guards after writes and avoid treating explicit interface implementations as callable async alternatives. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../VSTHRD104OfferAsyncOptionAnalyzer.cs | 30 ++++++- .../Utils.cs | 4 +- .../VSTHRD104OfferAsyncOptionAnalyzerTests.cs | 84 +++++++++++++++++++ 3 files changed, 116 insertions(+), 2 deletions(-) diff --git a/src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD104OfferAsyncOptionAnalyzer.cs b/src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD104OfferAsyncOptionAnalyzer.cs index c573db924..bc401f766 100644 --- a/src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD104OfferAsyncOptionAnalyzer.cs +++ b/src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD104OfferAsyncOptionAnalyzer.cs @@ -125,7 +125,9 @@ private bool IsGuardedBySuccessfulCompletion(SyntaxNodeAnalysisContext context, .TakeWhile(ancestor => ancestor is not LocalFunctionStatementSyntax) .OfType()) { - if (ifStatement.Statement.Span.Contains(resultAccess.Span) && ConditionProvesSuccessfulCompletion(ifStatement.Condition, taskSymbol)) + if (ifStatement.Statement.Span.Contains(resultAccess.Span) + && ConditionProvesSuccessfulCompletion(ifStatement.Condition, taskSymbol) + && !MayWriteSymbolBefore(ifStatement.Statement, resultAccess, taskSymbol)) { return true; } @@ -157,6 +159,32 @@ bool ConditionProvesSuccessfulCompletion(ExpressionSyntax condition, ISymbol exp && Utils.IsTask(containingType) && SymbolEqualityComparer.Default.Equals(expectedTaskSymbol, completedTaskSymbol); } + + bool MayWriteSymbolBefore(StatementSyntax statement, ExpressionSyntax expression, ISymbol expectedTaskSymbol) + { + foreach (SyntaxNode node in statement.DescendantNodes().Where(node => node.SpanStart < expression.SpanStart)) + { + ExpressionSyntax? writtenExpression = node switch + { + AssignmentExpressionSyntax assignment => assignment.Left, + PrefixUnaryExpressionSyntax prefix when prefix.IsKind(SyntaxKind.PreIncrementExpression) || prefix.IsKind(SyntaxKind.PreDecrementExpression) => prefix.Operand, + PostfixUnaryExpressionSyntax postfix when postfix.IsKind(SyntaxKind.PostIncrementExpression) || postfix.IsKind(SyntaxKind.PostDecrementExpression) => postfix.Operand, + ArgumentSyntax argument when argument.RefOrOutKeyword.IsKind(SyntaxKind.RefKeyword) || argument.RefOrOutKeyword.IsKind(SyntaxKind.OutKeyword) => argument.Expression, + _ => null, + }; + + if (writtenExpression is not null && writtenExpression.DescendantNodesAndSelf() + .OfType() + .Any(candidate => SymbolEqualityComparer.Default.Equals( + context.SemanticModel.GetSymbolInfo(candidate, context.CancellationToken).Symbol, + expectedTaskSymbol))) + { + return true; + } + } + + return false; + } } } } diff --git a/src/Microsoft.VisualStudio.Threading.Analyzers/Utils.cs b/src/Microsoft.VisualStudio.Threading.Analyzers/Utils.cs index 3b48c2862..9d5c8b547 100644 --- a/src/Microsoft.VisualStudio.Threading.Analyzers/Utils.cs +++ b/src/Microsoft.VisualStudio.Threading.Analyzers/Utils.cs @@ -332,7 +332,9 @@ public static bool HasAsyncAlternative(this IMethodSymbol methodSymbol, Cancella if (methodSymbol.IsExtensionMethod && !methodSymbol.Parameters.IsDefaultOrEmpty && methodSymbol.Parameters[0].Type is INamedTypeSymbol receiverType) { - IEnumerable receiverMembers = receiverType.AllInterfaces.SelectMany(iface => iface.GetMembers(asyncMethodName)); + IEnumerable receiverMembers = receiverType.TypeKind == TypeKind.Interface + ? receiverType.AllInterfaces.SelectMany(iface => iface.GetMembers(asyncMethodName)) + : Enumerable.Empty(); for (INamedTypeSymbol? currentType = receiverType; currentType is not null; currentType = currentType.BaseType) { receiverMembers = receiverMembers.Concat(currentType.GetMembers(asyncMethodName)); diff --git a/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/VSTHRD104OfferAsyncOptionAnalyzerTests.cs b/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/VSTHRD104OfferAsyncOptionAnalyzerTests.cs index 95b694d0f..4b3f02e1a 100644 --- a/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/VSTHRD104OfferAsyncOptionAnalyzerTests.cs +++ b/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/VSTHRD104OfferAsyncOptionAnalyzerTests.cs @@ -146,6 +146,33 @@ public static void Clear(this Service service, string category) await CSVerify.VerifyAnalyzerAsync(test); } + [Fact] + public async Task JTFRunFromPublicExtensionMethodWithExplicitInterfaceAsyncMethod_GeneratesWarning() + { + var test = @" +using System.Threading.Tasks; +using Microsoft.VisualStudio.Threading; + +public interface IService { + Task ClearAsync(); +} + +public class Service : IService { + Task IService.ClearAsync() => Task.CompletedTask; +} + +public static class ServiceExtensions { + private static readonly JoinableTaskFactory Jtf = new JoinableTaskFactory(new JoinableTaskContext()); + + public static void Clear(this Service service) + => Jtf.Run(() => ((IService)service).ClearAsync()); +} +"; + + DiagnosticResult expected = CSVerify.Diagnostic().WithSpan(17, 16, 17, 19); + await CSVerify.VerifyAnalyzerAsync(test, expected); + } + [Fact] public async Task TaskResultGuardedByIsCompletedSuccessfully_GeneratesNoWarning() { @@ -276,4 +303,61 @@ public int GetResult(Task task) { analyzerTest.ExpectedDiagnostics.Add(CSVerify.Diagnostic().WithSpan(7, 41, 7, 47)); await analyzerTest.RunAsync(); } + + [Fact] + public async Task TaskResultAfterReassignmentWithinCompletionGuard_GeneratesWarning() + { + var test = @" +using System.Threading.Tasks; + +public class Test { + public int GetResult(Task task) { + if (task.IsCompletedSuccessfully) { + task = new TaskCompletionSource().Task; + return task.Result; + } + + return 0; + } +} +"; + + var analyzerTest = new CSVerify.Test + { + TestCode = test, + ReferenceAssemblies = Microsoft.CodeAnalysis.Testing.ReferenceAssemblies.Net.Net80, + }; + analyzerTest.ExpectedDiagnostics.Add(CSVerify.Diagnostic().WithSpan(8, 25, 8, 31)); + await analyzerTest.RunAsync(); + } + + [Fact] + public async Task TaskResultAfterRefWriteWithinCompletionGuard_GeneratesWarning() + { + var test = @" +using System.Threading.Tasks; + +public class Test { + public int GetResult(Task task) { + if (task.IsCompletedSuccessfully) { + Replace(ref task); + return task.Result; + } + + return 0; + } + + private static void Replace(ref Task task) + => task = new TaskCompletionSource().Task; +} +"; + + var analyzerTest = new CSVerify.Test + { + TestCode = test, + ReferenceAssemblies = Microsoft.CodeAnalysis.Testing.ReferenceAssemblies.Net.Net80, + }; + analyzerTest.ExpectedDiagnostics.Add(CSVerify.Diagnostic().WithSpan(8, 25, 8, 31)); + await analyzerTest.RunAsync(); + } } From 9a99ea257fbd90bb30986f34ba603ceec2640f2e Mon Sep 17 00:00:00 2001 From: Andrew Arnott Date: Thu, 20 Aug 2026 23:31:33 -0600 Subject: [PATCH 5/7] Invalidate completion proofs for ref aliases Treat taking a ref to a guarded task as a potential write before accessing Result. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../VSTHRD104OfferAsyncOptionAnalyzer.cs | 8 ++++++ .../VSTHRD104OfferAsyncOptionAnalyzerTests.cs | 28 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD104OfferAsyncOptionAnalyzer.cs b/src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD104OfferAsyncOptionAnalyzer.cs index bc401f766..6e132cf8f 100644 --- a/src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD104OfferAsyncOptionAnalyzer.cs +++ b/src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD104OfferAsyncOptionAnalyzer.cs @@ -164,6 +164,14 @@ bool MayWriteSymbolBefore(StatementSyntax statement, ExpressionSyntax expression { foreach (SyntaxNode node in statement.DescendantNodes().Where(node => node.SpanStart < expression.SpanStart)) { + if (node is RefExpressionSyntax refExpression + && SymbolEqualityComparer.Default.Equals( + context.SemanticModel.GetSymbolInfo(refExpression.Expression, context.CancellationToken).Symbol, + expectedTaskSymbol)) + { + return true; + } + ExpressionSyntax? writtenExpression = node switch { AssignmentExpressionSyntax assignment => assignment.Left, diff --git a/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/VSTHRD104OfferAsyncOptionAnalyzerTests.cs b/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/VSTHRD104OfferAsyncOptionAnalyzerTests.cs index 4b3f02e1a..977a3485c 100644 --- a/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/VSTHRD104OfferAsyncOptionAnalyzerTests.cs +++ b/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/VSTHRD104OfferAsyncOptionAnalyzerTests.cs @@ -360,4 +360,32 @@ private static void Replace(ref Task task) analyzerTest.ExpectedDiagnostics.Add(CSVerify.Diagnostic().WithSpan(8, 25, 8, 31)); await analyzerTest.RunAsync(); } + + [Fact] + public async Task TaskResultAfterRefAliasWriteWithinCompletionGuard_GeneratesWarning() + { + var test = @" +using System.Threading.Tasks; + +public class Test { + public int GetResult(Task task) { + if (task.IsCompletedSuccessfully) { + ref Task alias = ref task; + alias = new TaskCompletionSource().Task; + return task.Result; + } + + return 0; + } +} +"; + + var analyzerTest = new CSVerify.Test + { + TestCode = test, + ReferenceAssemblies = Microsoft.CodeAnalysis.Testing.ReferenceAssemblies.Net.Net80, + }; + analyzerTest.ExpectedDiagnostics.Add(CSVerify.Diagnostic().WithSpan(9, 25, 9, 31)); + await analyzerTest.RunAsync(); + } } From bd541fb96d13a21571804bd6f6167ba680d439f6 Mon Sep 17 00:00:00 2001 From: Andrew Arnott Date: Thu, 20 Aug 2026 23:36:02 -0600 Subject: [PATCH 6/7] Track prior ref aliases in completion proofs Scan the containing executable scope for ref aliases created before guarded Result access. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../VSTHRD104OfferAsyncOptionAnalyzer.cs | 23 +++++++++------ .../VSTHRD104OfferAsyncOptionAnalyzerTests.cs | 28 +++++++++++++++++++ 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD104OfferAsyncOptionAnalyzer.cs b/src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD104OfferAsyncOptionAnalyzer.cs index 6e132cf8f..2eb9c39ae 100644 --- a/src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD104OfferAsyncOptionAnalyzer.cs +++ b/src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD104OfferAsyncOptionAnalyzer.cs @@ -162,16 +162,23 @@ bool ConditionProvesSuccessfulCompletion(ExpressionSyntax condition, ISymbol exp bool MayWriteSymbolBefore(StatementSyntax statement, ExpressionSyntax expression, ISymbol expectedTaskSymbol) { - foreach (SyntaxNode node in statement.DescendantNodes().Where(node => node.SpanStart < expression.SpanStart)) + SyntaxNode executableScope = expression.Ancestors().FirstOrDefault(ancestor => + ancestor is AnonymousFunctionExpressionSyntax + or LocalFunctionStatementSyntax + or BaseMethodDeclarationSyntax + or AccessorDeclarationSyntax) ?? statement; + if (executableScope.DescendantNodes() + .OfType() + .Where(refExpression => refExpression.SpanStart < expression.SpanStart) + .Any(refExpression => SymbolEqualityComparer.Default.Equals( + context.SemanticModel.GetSymbolInfo(refExpression.Expression, context.CancellationToken).Symbol, + expectedTaskSymbol))) { - if (node is RefExpressionSyntax refExpression - && SymbolEqualityComparer.Default.Equals( - context.SemanticModel.GetSymbolInfo(refExpression.Expression, context.CancellationToken).Symbol, - expectedTaskSymbol)) - { - return true; - } + return true; + } + foreach (SyntaxNode node in statement.DescendantNodes().Where(node => node.SpanStart < expression.SpanStart)) + { ExpressionSyntax? writtenExpression = node switch { AssignmentExpressionSyntax assignment => assignment.Left, diff --git a/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/VSTHRD104OfferAsyncOptionAnalyzerTests.cs b/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/VSTHRD104OfferAsyncOptionAnalyzerTests.cs index 977a3485c..144cc4ebf 100644 --- a/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/VSTHRD104OfferAsyncOptionAnalyzerTests.cs +++ b/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/VSTHRD104OfferAsyncOptionAnalyzerTests.cs @@ -378,6 +378,34 @@ public int GetResult(Task task) { return 0; } } +"; + + var analyzerTest = new CSVerify.Test + { + TestCode = test, + ReferenceAssemblies = Microsoft.CodeAnalysis.Testing.ReferenceAssemblies.Net.Net80, + }; + analyzerTest.ExpectedDiagnostics.Add(CSVerify.Diagnostic().WithSpan(9, 25, 9, 31)); + await analyzerTest.RunAsync(); + } + + [Fact] + public async Task TaskResultAfterPriorRefAliasWriteWithinCompletionGuard_GeneratesWarning() + { + var test = @" +using System.Threading.Tasks; + +public class Test { + public int GetResult(Task task) { + ref Task alias = ref task; + if (task.IsCompletedSuccessfully) { + alias = new TaskCompletionSource().Task; + return task.Result; + } + + return 0; + } +} "; var analyzerTest = new CSVerify.Test From 6346149080df6c03f52d67bb88a958ec1b8b6b5b Mon Sep 17 00:00:00 2001 From: Andrew Arnott Date: Thu, 20 Aug 2026 23:41:47 -0600 Subject: [PATCH 7/7] Reject condition mutations in completion proofs Keep guarded Result suppression sound when the if condition writes the task and streamline async-alternative lookup. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../VSTHRD104OfferAsyncOptionAnalyzer.cs | 7 +++-- .../Utils.cs | 17 ++++++---- .../VSTHRD104OfferAsyncOptionAnalyzerTests.cs | 31 +++++++++++++++++++ 3 files changed, 46 insertions(+), 9 deletions(-) diff --git a/src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD104OfferAsyncOptionAnalyzer.cs b/src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD104OfferAsyncOptionAnalyzer.cs index 2eb9c39ae..f78efc352 100644 --- a/src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD104OfferAsyncOptionAnalyzer.cs +++ b/src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD104OfferAsyncOptionAnalyzer.cs @@ -127,6 +127,7 @@ private bool IsGuardedBySuccessfulCompletion(SyntaxNodeAnalysisContext context, { if (ifStatement.Statement.Span.Contains(resultAccess.Span) && ConditionProvesSuccessfulCompletion(ifStatement.Condition, taskSymbol) + && !MayWriteSymbolBefore(ifStatement.Condition, resultAccess, taskSymbol) && !MayWriteSymbolBefore(ifStatement.Statement, resultAccess, taskSymbol)) { return true; @@ -160,13 +161,13 @@ bool ConditionProvesSuccessfulCompletion(ExpressionSyntax condition, ISymbol exp && SymbolEqualityComparer.Default.Equals(expectedTaskSymbol, completedTaskSymbol); } - bool MayWriteSymbolBefore(StatementSyntax statement, ExpressionSyntax expression, ISymbol expectedTaskSymbol) + bool MayWriteSymbolBefore(SyntaxNode region, ExpressionSyntax expression, ISymbol expectedTaskSymbol) { SyntaxNode executableScope = expression.Ancestors().FirstOrDefault(ancestor => ancestor is AnonymousFunctionExpressionSyntax or LocalFunctionStatementSyntax or BaseMethodDeclarationSyntax - or AccessorDeclarationSyntax) ?? statement; + or AccessorDeclarationSyntax) ?? region; if (executableScope.DescendantNodes() .OfType() .Where(refExpression => refExpression.SpanStart < expression.SpanStart) @@ -177,7 +178,7 @@ or BaseMethodDeclarationSyntax return true; } - foreach (SyntaxNode node in statement.DescendantNodes().Where(node => node.SpanStart < expression.SpanStart)) + foreach (SyntaxNode node in region.DescendantNodes().Where(node => node.SpanStart < expression.SpanStart)) { ExpressionSyntax? writtenExpression = node switch { diff --git a/src/Microsoft.VisualStudio.Threading.Analyzers/Utils.cs b/src/Microsoft.VisualStudio.Threading.Analyzers/Utils.cs index 9d5c8b547..a3335ee14 100644 --- a/src/Microsoft.VisualStudio.Threading.Analyzers/Utils.cs +++ b/src/Microsoft.VisualStudio.Threading.Analyzers/Utils.cs @@ -332,16 +332,21 @@ public static bool HasAsyncAlternative(this IMethodSymbol methodSymbol, Cancella if (methodSymbol.IsExtensionMethod && !methodSymbol.Parameters.IsDefaultOrEmpty && methodSymbol.Parameters[0].Type is INamedTypeSymbol receiverType) { - IEnumerable receiverMembers = receiverType.TypeKind == TypeKind.Interface - ? receiverType.AllInterfaces.SelectMany(iface => iface.GetMembers(asyncMethodName)) - : Enumerable.Empty(); + if (receiverType.TypeKind == TypeKind.Interface + && receiverType.AllInterfaces.Any(iface => iface.GetMembers(asyncMethodName).Any(alt => IsXAtLeastAsPublicAsY(alt, methodSymbol)))) + { + return true; + } + for (INamedTypeSymbol? currentType = receiverType; currentType is not null; currentType = currentType.BaseType) { - receiverMembers = receiverMembers.Concat(currentType.GetMembers(asyncMethodName)); + if (currentType.GetMembers(asyncMethodName).Any(alt => IsXAtLeastAsPublicAsY(alt, methodSymbol))) + { + return true; + } } - return receiverMembers - .Any(alt => IsXAtLeastAsPublicAsY(alt, methodSymbol)); + return false; } return false; diff --git a/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/VSTHRD104OfferAsyncOptionAnalyzerTests.cs b/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/VSTHRD104OfferAsyncOptionAnalyzerTests.cs index 144cc4ebf..0618adaea 100644 --- a/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/VSTHRD104OfferAsyncOptionAnalyzerTests.cs +++ b/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/VSTHRD104OfferAsyncOptionAnalyzerTests.cs @@ -416,4 +416,35 @@ public int GetResult(Task task) { analyzerTest.ExpectedDiagnostics.Add(CSVerify.Diagnostic().WithSpan(9, 25, 9, 31)); await analyzerTest.RunAsync(); } + + [Fact] + public async Task TaskResultAfterRefWriteInCompletionCondition_GeneratesWarning() + { + var test = @" +using System.Threading.Tasks; + +public class Test { + public int GetResult(Task task) { + if (task.IsCompletedSuccessfully && Replace(ref task)) { + return task.Result; + } + + return 0; + } + + private static bool Replace(ref Task task) { + task = new TaskCompletionSource().Task; + return true; + } +} +"; + + var analyzerTest = new CSVerify.Test + { + TestCode = test, + ReferenceAssemblies = Microsoft.CodeAnalysis.Testing.ReferenceAssemblies.Net.Net80, + }; + analyzerTest.ExpectedDiagnostics.Add(CSVerify.Diagnostic().WithSpan(7, 25, 7, 31)); + await analyzerTest.RunAsync(); + } }