Fix VSTHRD002 completion analysis and extensibility - #1648
Conversation
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR extends VSTHRD002 completion analysis, configurable blocking-method detection, and safer await code fixes.
Changes:
- Adds symbol-aware completion and reassignment analysis.
- Supports configured synchronous blocking methods.
- Hardens code fixes and updates tests and documentation.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Summary |
|---|---|
test/Microsoft.VisualStudio.Threading.Analyzers.Tests/VSTHRD002UseJtfRunAnalyzerTests.cs |
Adds analyzer and code-fix regression coverage. |
test/Microsoft.VisualStudio.Threading.Analyzers.Tests/MultiAnalyzerTests.cs |
Updates completed-task diagnostic expectations. |
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD002UseJtfRunAnalyzer.cs |
Adds completion and configuration analysis. |
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs |
Moderate (2 votes): Misses completion guards expressed with an else branch. |
src/Microsoft.VisualStudio.Threading.Analyzers.CodeFixes/VSTHRD002UseJtfRunCodeFixWithAwait.cs |
Critical (2 votes): Generic fallback can generate non-compiling fixes for parenthesized awaiters and Task.WhenAll(...).Result. |
docfx/analyzers/VSTHRD002.md |
Documents completion behavior. |
docfx/analyzers/configuration.md |
Documents custom blocking-method configuration. |
Suppressed comments (12)
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:399
- This only compares the symbol for the whole assignment left-hand side. For a deconstruction such as
(task, other) = (Task.Run(() => 2), 0),assignment.Leftis a tuple and has no symbol, so the write is missed; a priorawait taskthen incorrectly suppresses the warning for the newly assigned task'sResult. Walk the writable elements of deconstruction assignments (and handle member/other LHS forms without treating their receivers as writes).
foreach (AssignmentExpressionSyntax assignment in node.DescendantNodes(DescendIntoChildren).OfType<AssignmentExpressionSyntax>())
{
if (assignment.SpanStart > afterPosition
&& assignment.SpanStart < beforePosition
&& SymbolEqualityComparer.Default.Equals(context.SemanticModel.GetSymbolInfo(assignment.Left, context.CancellationToken).Symbol, taskSymbol))
{
return true;
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:190
- This traversal stops at any enclosing statement other than an
ifor a block. Consequently, an unconditionalawait task;before atryblock is not considered when analyzingtask.Resultinside that block, so the new completion proof still reports a false positive for a nested block. Please continue through safe constructs such astry(while retaining the existing write/control-flow checks).
StatementSyntax? outerStatement = containingStatement.Ancestors().OfType<StatementSyntax>().FirstOrDefault(statement => statement.Parent is BlockSyntax);
if (outerStatement is not IfStatementSyntax and not BlockSyntax)
{
return false;
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:399
- The
DescendIntoChildrenpredicate skips every anonymous function when looking for intervening writes. That makes a definite synchronous write invisible, for example((Action)(() => task = Task.Run(() => 2)))();betweenawait taskandtask.Result; the lambda is invoked immediately, but the analyzer still carries the old completion proof and suppresses VSTHRD002. Nested bodies should not be discarded without accounting for calls that execute them synchronously, or the analysis should conservatively invalidate the proof.
static bool DescendIntoChildren(SyntaxNode node) => node is not AnonymousFunctionExpressionSyntax and not LocalFunctionStatementSyntax;
foreach (AssignmentExpressionSyntax assignment in node.DescendantNodes(DescendIntoChildren).OfType<AssignmentExpressionSyntax>())
{
if (assignment.SpanStart > afterPosition
&& assignment.SpanStart < beforePosition
&& SymbolEqualityComparer.Default.Equals(context.SemanticModel.GetSymbolInfo(assignment.Left, context.CancellationToken).Symbol, taskSymbol))
{
return true;
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:181
- For a member access inside a deferred lambda,
FirstAncestorOrSelf<StatementSyntax>()is the lambda's containing declaration, so this scan only considers writes before the lambda is created. It therefore suppresses a real violation inawait task; Action a = () => task.Result; task = Task.Run(...); a();: the lambda executes after the reassignment and can block on the new task, but the earlier await proof is still used. Captured task completions must be invalidated by writes after capture (or the deferred delegate must be analyzed with an appropriate capture boundary).
StatementSyntax? containingStatement = memberAccessSyntax.FirstAncestorOrSelf<StatementSyntax>();
if (containingStatement is null)
{
return false;
}
while (containingStatement.Parent is BlockSyntax block)
{
if (MayReassignTask(context, containingStatement, taskSymbol, containingStatement.SpanStart - 1, memberAccessSyntax.SpanStart))
{
return false;
}
int statementIndex = block.Statements.IndexOf(containingStatement);
for (int i = statementIndex - 1; i >= 0; i--)
{
StatementSyntax statement = block.Statements[i];
if (StatementCompletesTask(context, statement, taskSymbol))
{
return true;
}
if (MayReassignTask(context, statement, taskSymbol))
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:305
StatementCompletesTaskhandles direct await statements and the special negative-guard form, but not an unconditional nested block. For{ await task; }followed bytask.Result,TryGetAwaitExpressionrejects the block because its inner expression statement is treated as conditional, and the block is never passed toStatementDefinitelyAwaitsTask, so the result is still reported despite the documented awaited-task proof. Propagate completion throughBlockSyntaxas well.
return statement is IfStatementSyntax { Else: null } ifStatement
&& ConditionProvesCompletion(context, ifStatement.Condition, taskSymbol, conditionValue: false)
&& StatementDefinitelyAwaitsTask(context, ifStatement.Statement, taskSymbol);
}
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:149
- This descriptor-independent completion suppression is also used by VSTHRD102, which passes
CommonInterest.JTFSyncBlockers(includingJoinableTask.Join). Consequently,if (joinableTask.IsCompleted) joinableTask.Join();now skips VSTHRD102 even though that rule limits synchronous frames in internal members. ScopeHasTaskCompletedto VSTHRD002 or make it an opt-in for callers.
private static bool HasTaskCompleted(SyntaxNodeAnalysisContext context, MemberAccessExpressionSyntax memberAccessSyntax)
{
ISymbol? taskSymbol = context.SemanticModel.GetSymbolInfo(GetTaskReceiver(memberAccessSyntax), context.CancellationToken).Symbol;
if (taskSymbol is not ILocalSymbol and not IParameterSymbol)
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:380
- This proof returns true as soon as any
Task.WhenAllargument is the task symbol, without checking for ref/out writes inside the same invocation. Inawait Task.WhenAll(task, Replace(ref task)),Replacecan replacetaskwith an incomplete task after the first argument is evaluated; a latertask.Resultis then still a synchronous wait but is suppressed. Ref/out mutations in the awaited expression must invalidate this proof, or the analyzer should conservatively avoid suppressing it.
return whenAllInvocation.ArgumentList.Arguments.Any(argument => IsSameTask(context, argument.Expression, taskSymbol));
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:397
- The reassignment scan compares assignment symbols directly with
taskSymbol, so it misses writes through ref-local aliases. For example, afterref Task<int> alias = ref task, assigning a new task toaliasinside a completion guard does not invalidate the proof, andtask.Resultcan be incorrectly suppressed. Track ref aliases in the enclosing executable scope, including aliases declared before the guarded region.
foreach (AssignmentExpressionSyntax assignment in node.DescendantNodes(DescendIntoChildren).OfType<AssignmentExpressionSyntax>())
{
if (assignment.SpanStart > afterPosition
&& assignment.SpanStart < beforePosition
&& SymbolEqualityComparer.Default.Equals(context.SemanticModel.GetSymbolInfo(assignment.Left, context.CancellationToken).Symbol, taskSymbol))
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:176
- The completion search only walks statements preceding the containing statement, so it ignores an ordered await in the same statement.
Consume(await task, task.Result)therefore still reports VSTHRD002 even though C# evaluates the first argument and completestaskbefore readingResult. Account for awaits that occur earlier in the containing statement.
int statementIndex = block.Statements.IndexOf(containingStatement);
for (int i = statementIndex - 1; i >= 0; i--)
{
StatementSyntax statement = block.Statements[i];
if (StatementCompletesTask(context, statement, taskSymbol))
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:344
TryGetAwaitExpressionreturns only the last unconditionally executed await. InConsume(await task, await otherTask); task.Result, it selects theotherTaskawait and misses the earlier await oftask, producing a false positive. The analysis needs to consider all relevant ordered await candidates instead of stopping at the first match in reverse order.
foreach (AwaitExpressionSyntax candidate in statement.DescendantNodes(DescendIntoChildren).OfType<AwaitExpressionSyntax>().Reverse())
{
IEnumerable<SyntaxNode> ancestorsWithinStatement = candidate.Ancestors().TakeWhile(node => node != statement);
bool isConditionallyExecuted = ancestorsWithinStatement.Any(
node => node is StatementSyntax or ConditionalExpressionSyntax or SwitchExpressionSyntax or ConditionalAccessExpressionSyntax
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD002UseJtfRunAnalyzer.cs:251
- The continuation reassignment check has the same whole-LHS limitation: a deconstruction assignment to the continuation parameter is not represented by a symbol on
assignment.Left. For example,(t, other) = (Task.Run(() => 6), 0); Console.WriteLine(t.Result);is treated as using the completed antecedent and the warning is suppressed, althoughtnow refers to an incomplete task. Inspect deconstruction elements here as well.
foreach (AssignmentExpressionSyntax assignment in continuation.DescendantNodes().OfType<AssignmentExpressionSyntax>())
{
if (assignment.SpanStart < beforePosition
&& SymbolEqualityComparer.Default.Equals(context.SemanticModel.GetSymbolInfo(assignment.Left, context.CancellationToken).Symbol, taskParameter))
{
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD002UseJtfRunAnalyzer.cs:251
- For a direct access in the continuation body,
continuation.DescendantNodes()also descends into lambdas that are merely declared before the access. An assignment inside such a lambda is not executed when the lambda is created, but it is counted as a prior reassignment; e.g.Action reassign = () => t = Task.Run(() => 6); Console.WriteLine(t.Result);gets a warning even thoughtis still the completed antecedent. Skip nested function bodies for non-nested accesses (while keeping the conservative handling for accesses inside nested functions).
foreach (AssignmentExpressionSyntax assignment in continuation.DescendantNodes().OfType<AssignmentExpressionSyntax>())
{
if (assignment.SpanStart < beforePosition
&& SymbolEqualityComparer.Default.Equals(context.SemanticModel.GetSymbolInfo(assignment.Left, context.CancellationToken).Symbol, taskParameter))
{
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.
Suppressed comments (15)
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:421
MayReassignTaskonly inspects assignment expressions whose left side resolves directly totask(and ref/out invocation arguments). A ref local alias is missed: afterref Task<int> alias = ref task; alias = new TaskCompletionSource<int>().Task;,task.Resultcan still block even though this guard is treated as proof of completion. Track ref-local aliases (and writes through them), or conservatively invalidate the proof when such an alias is introduced.
foreach (ArgumentSyntax argument in node.DescendantNodes(DescendIntoChildren).OfType<ArgumentSyntax>())
{
if (argument.SpanStart > afterPosition
&& argument.SpanStart < beforePosition
&& (argument.RefKindKeyword.IsKind(SyntaxKind.RefKeyword) || argument.RefKindKeyword.IsKind(SyntaxKind.OutKeyword))
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:317
- When the completion proof comes from a compound condition, writes performed while evaluating that condition are not checked. For example,
if (task.IsCompletedSuccessfully && Replace(ref task)) { } else { await task; }can reassigntaskto an incomplete task inReplace; the positive path then skips the await, but this code still treats the wholeifas completing the task and suppresses a latertask.Result. Invalidate the proof when the condition itself may reassign the task.
return (ConditionProvesCompletion(context, ifStatement.Condition, taskSymbol, conditionValue: true)
&& !MayReassignTask(context, ifStatement.Statement, taskSymbol)
&& StatementDefinitelyAwaitsTask(context, ifStatement.Else.Statement, taskSymbol))
|| (ConditionProvesCompletion(context, ifStatement.Condition, taskSymbol, conditionValue: false)
&& StatementDefinitelyAwaitsTask(context, ifStatement.Statement, taskSymbol)
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:414
- Tuple/deconstruction assignments are not recognized as writes here because GetSymbolInfo on a TupleExpressionSyntax is null. For example, after
await task; (task, other) = (Task.Run(...), ...);, the scan misses the reassignment and suppresses the latertask.Resultdiagnostic even though the new task is incomplete. Inspect the symbols in the assignment target (or use the operation write) rather than only the whole left expression.
if (assignment.SpanStart > afterPosition
&& assignment.SpanStart < beforePosition
&& SymbolEqualityComparer.Default.Equals(context.SemanticModel.GetSymbolInfo(assignment.Left, context.CancellationToken).Symbol, taskSymbol))
{
return true;
}
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:143
- Parentheses are unwrapped only after the
GetAwaiter/ConfigureAwaitpatterns are checked. Consequently(task.GetAwaiter()).GetResult()is mapped to theGetAwaitermethod symbol instead oftask, so even afterawait taskthis access still gets a VSTHRD002 warning. Normalize the receiver before each chain match, as the code fix already does for this syntax.
private static ExpressionSyntax GetTaskReceiver(MemberAccessExpressionSyntax memberAccessSyntax)
{
ExpressionSyntax receiver = memberAccessSyntax.Expression;
if (receiver is InvocationExpressionSyntax getAwaiterInvocation
&& getAwaiterInvocation.Expression is MemberAccessExpressionSyntax { Name.Identifier.ValueText: "GetAwaiter" } getAwaiterAccess)
{
receiver = getAwaiterAccess.Expression;
}
if (receiver is InvocationExpressionSyntax configureAwaitInvocation
&& configureAwaitInvocation.Expression is MemberAccessExpressionSyntax { Name.Identifier.ValueText: nameof(Task.ConfigureAwait) } configureAwaitAccess)
{
receiver = configureAwaitAccess.Expression;
}
return UnwrapParentheses(receiver);
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:299
- Writes inside the awaited expression are excluded from the invalidation check because only positions after
awaitExpression.Span.Endare examined. For example,await task.ConfigureAwait(Replace(ref task));can await the original task whileReplaceassigns a new, incomplete task to the variable; the followingtask.Resultis then incorrectly suppressed. Include ref/out writes and assignments within the await expression (or conservatively invalidate on any such write before proving completion).
private static bool StatementCompletesTask(SyntaxNodeAnalysisContext context, StatementSyntax statement, ISymbol taskSymbol)
{
if (TryGetAwaitExpression(statement, out AwaitExpressionSyntax? awaitExpression)
&& AwaitCompletesTask(context, awaitExpression, taskSymbol)
&& !MayReassignTask(context, statement, taskSymbol, awaitExpression.Span.End, statement.Span.End + 1))
{
return true;
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:358
TryGetAwaitExpressionreturns only the last unconditionally executed await in a statement. For example,Consume(await task, await other); task.Result;selectsawait other,AwaitCompletesTaskreturns false, and the completedtaskis still reported. Enumerate/select all unconditional awaits (or pass the tracked symbol into this search) so an earlier await/WhenAllproof is considered.
foreach (AwaitExpressionSyntax candidate in statement.DescendantNodes(DescendIntoChildren).OfType<AwaitExpressionSyntax>().Reverse())
{
IEnumerable<SyntaxNode> ancestorsWithinStatement = candidate.Ancestors().TakeWhile(node => node != statement);
bool isConditionallyExecuted = ancestorsWithinStatement.Any(
node => node is StatementSyntax or ConditionalExpressionSyntax or SwitchExpressionSyntax or ConditionalAccessExpressionSyntax
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:305
StatementCompletesTaskfalls through for a preceding standaloneBlockSyntax, even thoughTryGetAwaitExpressionintentionally rejects awaits nested under the block's child statement. Thus a valid sequence{ await task; } task.Result;is not recognized as completed. Handle a block with the existingStatementDefinitelyAwaitsTasklogic before theIfStatementSyntaxcheck.
if (statement is not IfStatementSyntax ifStatement)
{
return false;
}
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD002UseJtfRunAnalyzer.cs:251
- The continuation-specific reassignment scan has the same alias gap:
ref Task<int> alias = ref t; alias = ...; return t.Result;is not detected because the initializer is not anArgumentSyntaxand the assignment targetsalias. This causes the new nested-continuation suppression to hide a real blocking wait after the continuation parameter was changed.
foreach (AssignmentExpressionSyntax assignment in continuation.DescendantNodes().OfType<AssignmentExpressionSyntax>())
{
if (assignment.SpanStart < beforePosition
&& SymbolEqualityComparer.Default.Equals(context.SemanticModel.GetSymbolInfo(assignment.Left, context.CancellationToken).Symbol, taskParameter))
{
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD002UseJtfRunAnalyzer.cs:180
- The configured-method path bypasses the existing
nameofguard used byCSharpCommonInterest. Consequently,nameof(waiter.Join())can produce a VSTHRD002 diagnostic for a configuredJoin, even though the call is not executed. Skip configured-method reporting when the invocation is insidenameof, just as the built-in path does.
if (configuredSyncBlockingMethods.IsEmpty
|| context.SemanticModel.GetSymbolInfo(invocationExpressionSyntax, context.CancellationToken).Symbol is not IMethodSymbol invokedMethod)
{
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD002UseJtfRunAnalyzer.cs:200
- Diagnostics produced for configured methods use the same message as built-in task/awaiter waits, which says “Synchronously waiting on tasks or awaiters...”. The documented configuration and the added test support arbitrary blockers such as
CustomWaiter.Join, so users receive a misleading message for those diagnostics. Use a message/descriptor that also describes configured synchronous blockers, or provide a distinct configured-method message.
ImmutableDictionary<string, string?> properties = ImmutableDictionary<string, string?>.Empty.Add("SuppressAwaitCodeFix", null);
context.ReportDiagnostic(Diagnostic.Create(Descriptor, methodName.GetLocation(), properties));
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD002UseJtfRunAnalyzer.cs:253
- Continuation reassignment detection also misses deconstruction writes because
GetSymbolInfoon the tuple left-hand side is null. A continuation such as(t, other) = (Task.Run(...), ...); useResultLater();can therefore suppresst.Resulteven thoughtnow refers to an incomplete task. Check the symbols within the assignment target, not just the target expression itself.
foreach (AssignmentExpressionSyntax assignment in continuation.DescendantNodes().OfType<AssignmentExpressionSyntax>())
{
if (assignment.SpanStart < beforePosition
&& SymbolEqualityComparer.Default.Equals(context.SemanticModel.GetSymbolInfo(assignment.Left, context.CancellationToken).Symbol, taskParameter))
{
return true;
}
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD002UseJtfRunAnalyzer.cs:195
- Conditional-access calls use
MemberBindingExpressionSyntax, sowaiter?.Join()resolves to the configured method but this switch producesmethodName = nulland skips the diagnostic. This leaves a valid instance-method call unreported despite the documented extensibility support. Include member-binding expressions when selecting the diagnostic name.
SimpleNameSyntax? methodName = invocationExpressionSyntax.Expression switch
{
MemberAccessExpressionSyntax memberAccess => memberAccess.Name,
SimpleNameSyntax simpleName => simpleName,
_ => null,
};
src/Microsoft.VisualStudio.Threading.Analyzers.CodeFixes/VSTHRD002UseJtfRunCodeFixWithAwait.cs:185
- The new invocation matcher correctly narrows the wait cases, but the following member-access fallback still offers a rewrite when the blocking access is part of a larger chain. For
task.Result[0]ortask.Result.ToString(), replacing onlytask.Resultwithawait taskyields an invalid or differently bound expression (such asawait task[0]) rather than(await task)[0]. Do not offer this fix for chained receivers, or rewrite the complete expression with the required parentheses.
else if (FindInstanceWaitReceiver(parentInvocation) is object)
{
// This method will not return null for the provided 'target' argument
transform = NullableHelpers.AsNonNullReturnUnchecked<ExpressionSyntax, CancellationToken, ExpressionSyntax>(FindInstanceWaitReceiver);
target = parentInvocation!;
src/Microsoft.VisualStudio.Threading.Analyzers.CodeFixes/VSTHRD002UseJtfRunCodeFixWithAwait.cs:212
CanUseAwaitCodeFixonly validatesTask.Waitwhen the resolved containing type isTask; a custom extension such asstatic void Wait(this Task task)skips this branch and falls through toreturn true. The syntax matcher then transforms it toawait task, changing the call's semantics. Return false for anyWaitwhose resolved method is not the frameworkTask.Wait.
if (method.Name == nameof(Task.Wait) && Utils.IsTask(method.ContainingType))
{
return method.Parameters.IsEmpty;
}
src/Microsoft.VisualStudio.Threading.Analyzers.CodeFixes/VSTHRD002UseJtfRunCodeFixWithAwait.cs:128
FindGetAwaiterReceivervalidates only member names and call shape, not the resolved method. A valid extension such asGetResult(this TaskAwaiter awaiter, int value)can therefore be rewritten fromtask.GetAwaiter().GetResult(42)toawait task, silently dropping the argument. Require the symbol to be the parameterless framework awaiter's instanceGetResult(and the expectedGetAwaiter) before offering this fix.
ExpressionSyntax? FindGetAwaiterReceiver(ExpressionSyntax? from, CancellationToken cancellationToken = default(CancellationToken))
{
if (from is not InvocationExpressionSyntax
{
Expression: MemberAccessExpressionSyntax
{
Name.Identifier.ValueText: nameof(TaskAwaiter.GetResult),
} getResultAccess
})
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.
Suppressed comments (14)
Previously missed (4) — in code that hasn't changed since the last review.
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:395
Anyshort-circuits as soon as it sees the original task, so later arguments are not checked for writes. Withawait Task.WhenAll(task, Replace(ref task)); task.Result;, the first argument makes this return true whileReplacecan replacetaskwith a new, incomplete task; the result access can then block without a diagnostic. Inspect allWhenAllarguments for intervening writes before accepting the completion proof.
if (awaitedExpression is InvocationExpressionSyntax whenAllInvocation
&& context.SemanticModel.GetSymbolInfo(whenAllInvocation, context.CancellationToken).Symbol is IMethodSymbol whenAllMethod
&& whenAllMethod.Name == nameof(Task.WhenAll)
&& whenAllMethod.ContainingType.Name == nameof(Task)
&& whenAllMethod.ContainingType.BelongsToNamespace(Namespaces.SystemThreadingTasks))
{
return whenAllInvocation.ArgumentList.Arguments.Any(argument => IsSameTask(context, argument.Expression, taskSymbol));
}
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:406
- Skipping anonymous and local-function bodies here allows an executed closure to invalidate the proof without being detected. For example, after
await task, a closure can assigntask = Task.Run(...), be invoked, and thentask.Resultis analyzed; the declaration and call contain no visible assignment to this scan, so the earlier await incorrectly suppresses VSTHRD002. Track writes from closures that execute before the access, or conservatively invalidate the proof when that control flow cannot be established.
private static bool MayReassignTask(SyntaxNodeAnalysisContext context, SyntaxNode node, ISymbol taskSymbol)
=> MayReassignTask(context, node, taskSymbol, node.SpanStart - 1, node.Span.End + 1);
private static bool MayReassignTask(SyntaxNodeAnalysisContext context, SyntaxNode node, ISymbol taskSymbol, int afterPosition, int beforePosition)
{
static bool DescendIntoChildren(SyntaxNode node) => node is not AnonymousFunctionExpressionSyntax and not LocalFunctionStatementSyntax;
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:130
- The receiver is unwrapped only after the
GetAwaiter/ConfigureAwaitpattern checks, so(task.GetAwaiter()).GetResult()is resolved as theGetAwaiterinvocation rather than astask. Consequently, even afterawait task, this new completion proof still reports VSTHRD002 for the parenthesized form. Normalize parentheses before matching the receiver chain.
ExpressionSyntax receiver = UnwrapParentheses(memberAccessSyntax.Expression);
if (receiver is InvocationExpressionSyntax getAwaiterInvocation
&& getAwaiterInvocation.Expression is MemberAccessExpressionSyntax { Name.Identifier.ValueText: "GetAwaiter" } getAwaiterAccess)
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD002UseJtfRunAnalyzer.cs:224
- The continuation-specific receiver extraction has the same parenthesis gap: for
task.ContinueWith(t => (t.GetAwaiter()).GetResult()), the receiver is aParenthesizedExpressionSyntax, so this code never reachestand misses the fact that the continuation parameter is complete. The new nested-continuation suppression therefore still emits VSTHRD002 for this valid equivalent spelling; share a receiver-normalization path with the common completion analysis.
private static ISymbol? GetTaskReceiverSymbol(SyntaxNodeAnalysisContext context, MemberAccessExpressionSyntax memberAccessSyntax)
{
ExpressionSyntax receiver = UnwrapParentheses(memberAccessSyntax.Expression);
if (receiver is InvocationExpressionSyntax getAwaiterInvocation
&& getAwaiterInvocation.Expression is MemberAccessExpressionSyntax { Name.Identifier.ValueText: "GetAwaiter" } getAwaiterAccess)
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:310
- These guard proofs do not reject writes in the condition itself. For example,
if (!task.IsCompleted || (task = Replace()) == null) await task; task.Result;can take the false path after replacing an already-completed task, so the current task was never awaited, butConditionProvesCompletion(..., false)returns true from the left operand and suppresses VSTHRD002. Check the condition for assignments andref/outwrites before using either the single-branch or two-branch proof below.
if (ifStatement.Else is null)
{
return ConditionProvesCompletion(context, ifStatement.Condition, taskSymbol, conditionValue: false)
&& StatementDefinitelyAwaitsTask(context, ifStatement.Statement, taskSymbol);
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:385
- The direct-await path also accepts a task receiver without checking writes in the await expression's arguments. For example,
await task.ConfigureAwait(Replace(ref task)); task.Result;awaits the old task whileReplacereplaces the local during argument evaluation, butAwaitCompletesTaskstripsConfigureAwait, seestask, and returns true. Check the complete awaited expression for assignments andref/outwrites before using it as a completion proof.
private static bool AwaitCompletesTask(SyntaxNodeAnalysisContext context, AwaitExpressionSyntax awaitExpression, ISymbol taskSymbol)
{
ExpressionSyntax awaitedExpression = UnwrapParentheses(awaitExpression.Expression);
if (awaitedExpression is InvocationExpressionSyntax configureAwaitInvocation
&& configureAwaitInvocation.Expression is MemberAccessExpressionSyntax { Name.Identifier.ValueText: nameof(Task.ConfigureAwait) } configureAwaitAccess)
{
awaitedExpression = UnwrapParentheses(configureAwaitAccess.Expression);
}
if (IsSameTask(context, awaitedExpression, taskSymbol))
{
return true;
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:411
- This reassignment scan requires the entire left-hand expression to have the task symbol. In a valid deconstruction such as
(task, _) = (Task.Run(...), 0), the tuple left has no symbol, so the write is ignored; after an earlierawait task, a latertask.Resultis incorrectly treated as completed even thoughtasknow refers to a new task. Inspect the assignable elements/operations of deconstruction assignments.
foreach (AssignmentExpressionSyntax assignment in node.DescendantNodes(DescendIntoChildren).OfType<AssignmentExpressionSyntax>())
{
if (assignment.SpanStart > afterPosition
&& assignment.SpanStart < beforePosition
&& SymbolEqualityComparer.Default.Equals(context.SemanticModel.GetSymbolInfo(assignment.Left, context.CancellationToken).Symbol, taskSymbol))
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:176
- Completion is only searched in statements preceding the statement containing the member access. This misses C#'s left-to-right evaluation within one statement: in
Consume(await task, task.Result), theawait taskargument completestaskbefore theResultargument is evaluated, but this code still reports VSTHRD002. The completion proof should also inspect earlier await expressions in the current statement and honor intervening writes.
int statementIndex = block.Statements.IndexOf(containingStatement);
for (int i = statementIndex - 1; i >= 0; i--)
{
StatementSyntax statement = block.Statements[i];
if (StatementCompletesTask(context, statement, taskSymbol))
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:358
TryGetAwaitExpressiontreats an await directly in any statement's condition as unconditional. For example,do { break; } while (await task);has an await that is skipped by thebreak, but the precedingDoStatementis accepted byStatementCompletesTask; a followingtask.Resultis therefore incorrectly suppressed. Account for loop control flow (or conservatively reject await expressions in loop conditions/increments) before using them as completion proofs.
foreach (AwaitExpressionSyntax candidate in statement.DescendantNodes(DescendIntoChildren).OfType<AwaitExpressionSyntax>().Reverse())
{
IEnumerable<SyntaxNode> ancestorsWithinStatement = candidate.Ancestors().TakeWhile(node => node != statement);
bool isConditionallyExecuted = ancestorsWithinStatement.Any(
node => node is StatementSyntax or ConditionalExpressionSyntax or SwitchExpressionSyntax or ConditionalAccessExpressionSyntax
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:305
- A standalone nested block is treated as neither a completion statement nor an
ifhere. Consequently, inawait task;followed by a separate{ ... }block and thentask.Result, the scan skips the awaited statement inside that block and reports VSTHRD002 despite the task having completed. HandleBlockSyntaxwith the existing definite-await analysis.
if (statement is not IfStatementSyntax ifStatement)
{
return false;
}
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD002UseJtfRunAnalyzer.cs:251
- The continuation reassignment scan also misses deconstruction writes such as
(t, _) = (Task.Run(...), 0), becauseGetSymbolInfo(assignment.Left)is null for the tuple. A nested delegate that later readst.Resultis then suppressed even though the continuation parameter was replaced with a new, incomplete task. Handle deconstruction targets when checking for parameter reassignment.
private static bool IsTaskReassignedInContinuation(
SyntaxNodeAnalysisContext context,
AnonymousFunctionExpressionSyntax continuation,
MemberAccessExpressionSyntax memberAccess,
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD002UseJtfRunAnalyzer.cs:194
- Conditional-access calls use
MemberBindingExpressionSyntax, notMemberAccessExpressionSyntaxorSimpleNameSyntax. Thus a configured invocation such aswaiter?.Join()can match the configured symbol but leavesmethodNamenull, so no VSTHRD002 diagnostic is reported for this valid call form. IncludeMemberBindingExpressionSyntaxwhen selecting the diagnostic name (or useCSharpUtils.IsolateMethodName).
SimpleNameSyntax? methodName = invocationExpressionSyntax.Expression switch
{
MemberAccessExpressionSyntax memberAccess => memberAccess.Name,
SimpleNameSyntax simpleName => simpleName,
_ => null,
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD002UseJtfRunAnalyzer.cs:200
- The configured-method path reports directly here and bypasses
CSharpCommonInterest.ShouldIgnoreContext(CSharpCommonInterest.cs:43-59), the existing VSTHRD002 filter for Xaml2CS<auto-generated>code. Consequently, configuring a custom blocker causes warnings in generated files even though built-in blockers remain suppressed there. Apply the same generated-code check before reporting the configured diagnostic.
if (methodName is object)
{
ImmutableDictionary<string, string?> properties = ImmutableDictionary<string, string?>.Empty.Add("SuppressAwaitCodeFix", null);
context.ReportDiagnostic(Diagnostic.Create(Descriptor, methodName.GetLocation(), properties));
src/Microsoft.VisualStudio.Threading.Analyzers.CodeFixes/VSTHRD002UseJtfRunCodeFixWithAwait.cs:167
- The final
FindParentMemberAccessfallback still accepts every member-access diagnostic after these branches. For a known awaiter obtained through a non-GetAwaiterchain such asGetCustomAwaiter().GetResult(),FindGetAwaiterReceiverfails and the fallback offersawait GetCustomAwaiter(). The returned awaiter is not necessarily awaitable, so the generated fix can fail to compile; only use this fallback for recognized result properties or validate the transformed expression.
else if (FindInstanceWaitReceiver(parentInvocation) is object)
{
// This method will not return null for the provided 'target' argument
transform = NullableHelpers.AsNonNullReturnUnchecked<ExpressionSyntax, CancellationToken, ExpressionSyntax>(FindInstanceWaitReceiver);
target = parentInvocation!;
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.
Suppressed comments (13)
Previously missed (4) — in code that hasn't changed since the last review.
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:395
- A
Task.WhenAllargument can reassign the tracked variable through aref/outcall. Because this returns as soon as any direct argument matchestask, and the caller only checks writes after the whole await expression,await Task.WhenAll(task, Replace(ref task))can suppress VSTHRD002 even whenReplacestores an incomplete task intotask. Reject or analyze ref/out and assignment writes inside theWhenAllinvocation before using it as a completion proof.
if (awaitedExpression is InvocationExpressionSyntax whenAllInvocation
&& context.SemanticModel.GetSymbolInfo(whenAllInvocation, context.CancellationToken).Symbol is IMethodSymbol whenAllMethod
&& whenAllMethod.Name == nameof(Task.WhenAll)
&& whenAllMethod.ContainingType.Name == nameof(Task)
&& whenAllMethod.ContainingType.BelongsToNamespace(Namespaces.SystemThreadingTasks))
{
return whenAllInvocation.ArgumentList.Arguments.Any(argument => IsSameTask(context, argument.Expression, taskSymbol));
}
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:310
- This proof path does not account for writes in the guard condition itself. For example,
if (!task.IsCompleted || Replace(ref task)) { await task; }can skip the body afterReplacereplacestask, butConditionProvesCompletion(..., false)succeeds from!task.IsCompletedand the result after theifis then incorrectly treated as completed. Reject aMayReassignTaskin the condition before accepting this one-branch proof.
return ConditionProvesCompletion(context, ifStatement.Condition, taskSymbol, conditionValue: false)
&& StatementDefinitelyAwaitsTask(context, ifStatement.Statement, taskSymbol);
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:218
- The reassignment scan compares only the symbol written by the assignment/ref argument with
taskSymbol; it does not follow ref-local aliases. For example, afterref Task<int> alias = ref task,alias = Task.Run(...);inside this guard leavestask.Resultincorrectly suppressed because the guard'sMayReassignTaskcheck misses the write. Track ref aliases (or conservatively treat a ref-local write as invalidating the guarded task).
if (ifStatement.Statement.FullSpan.Contains(memberAccessSyntax.Span)
&& ConditionProvesCompletion(context, ifStatement.Condition, taskSymbol, conditionValue: true)
&& !MayReassignTask(context, ifStatement, taskSymbol, ifStatement.Condition.SpanStart - 1, memberAccessSyntax.SpanStart))
{
return true;
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD002UseJtfRunAnalyzer.cs:200
- Configured-method diagnostics bypass
ShouldIgnoreContext, whereas the built-in path goes throughCSharpCommonInterest.InspectMemberAccess, which suppresses Xaml2CS auto-generated code. Withvs-threading.SyncBlockingMethods.txtpresent, a configured wait in the auto-generated namespace is therefore reported despite the existingDoNotReportWarningOnCodeGeneratedByXaml2CSbehavior. Apply the same generated-code check before reporting here.
if (methodName is object)
{
ImmutableDictionary<string, string?> properties = ImmutableDictionary<string, string?>.Empty.Add("SuppressAwaitCodeFix", null);
context.ReportDiagnostic(Diagnostic.Create(Descriptor, methodName.GetLocation(), properties));
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:413
- Deconstruction assignments are not detected here because
GetSymbolInfo(assignment.Left, ...)is null when the left side is a tuple. Thusawait task; (task, other) = (Task.Run(() => 2), 0); task.Result;is treated as using the awaited task even though the variable was replaced. Assignment-target analysis needs to include deconstruction elements, not just a directly symbol-bearing left expression.
foreach (AssignmentExpressionSyntax assignment in node.DescendantNodes(DescendIntoChildren).OfType<AssignmentExpressionSyntax>())
{
if (assignment.SpanStart > afterPosition
&& assignment.SpanStart < beforePosition
&& SymbolEqualityComparer.Default.Equals(context.SemanticModel.GetSymbolInfo(assignment.Left, context.CancellationToken).Symbol, taskSymbol))
{
return true;
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:179
- Completion is only searched in statements before
containingStatement, so an await that precedes the result access in the same statement is ignored. For example,Consume(await task, task.Result);still gets a VSTHRD002 warning even though C# evaluates arguments left-to-right and the await completestaskbeforeResultis read. Please account for an unconditional await within the current statement when it occurs before the member access, while still checking for writes between them.
int statementIndex = block.Statements.IndexOf(containingStatement);
for (int i = statementIndex - 1; i >= 0; i--)
{
StatementSyntax statement = block.Statements[i];
if (StatementCompletesTask(context, statement, taskSymbol))
{
return true;
}
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:358
TryGetAwaitExpressionreturns the last unconditional await in a statement without considering which task it awaits. ThusConsume(await task, await otherTask); task.Result;selectsawait otherTask,AwaitCompletesTaskreturns false, and the earlier await oftaskis missed even though it proves completion. The helper/callers need to examine all unconditional await candidates until one completes the tracked task.
foreach (AwaitExpressionSyntax candidate in statement.DescendantNodes(DescendIntoChildren).OfType<AwaitExpressionSyntax>().Reverse())
{
IEnumerable<SyntaxNode> ancestorsWithinStatement = candidate.Ancestors().TakeWhile(node => node != statement);
bool isConditionallyExecuted = ancestorsWithinStatement.Any(
node => node is StatementSyntax or ConditionalExpressionSyntax or SwitchExpressionSyntax or ConditionalAccessExpressionSyntax
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:245
- The condition logic handles
&&when true and||when false, but not the safe converse where both operands independently prove completion.if (task.IsCompleted || task.IsCanceled) { task.Result; }guarantees that the task is complete on either true path, yet this code falls through and reports VSTHRD002. For a true||, accept the proof only when both operands prove completion.
if (conditionValue && binary.IsKind(SyntaxKind.LogicalAndExpression))
{
return ConditionProvesCompletion(context, binary.Left, taskSymbol, conditionValue: true)
|| ConditionProvesCompletion(context, binary.Right, taskSymbol, conditionValue: true);
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:360
- The unconditional-await filter treats awaits in loop-control expressions as unconditional because it stops before the enclosing loop statement. A
foriterator (for example,for (int i = 0; i < 0; i++, await task) { }) may never execute, so the subsequenttask.Resultis not proven safe even though this helper returns that await and suppresses the diagnostic. Account for loop control flow (and similardoconditions that can be skipped) before treating the candidate as completion proof.
IEnumerable<SyntaxNode> ancestorsWithinStatement = candidate.Ancestors().TakeWhile(node => node != statement);
bool isConditionallyExecuted = ancestorsWithinStatement.Any(
node => node is StatementSyntax or ConditionalExpressionSyntax or SwitchExpressionSyntax or ConditionalAccessExpressionSyntax
|| (node is BinaryExpressionSyntax binary
&& (binary.IsKind(SyntaxKind.LogicalAndExpression)
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:409
- The
DescendIntoChildrenpredicate skips anonymous and local-function bodies while looking for intervening writes. That hides writes from delegates that execute synchronously before the result, for exampleawait task; ((Action)(() => task = new TaskCompletionSource<int>().Task))(); task.Result;. The await is accepted as the prior completion and the invoked lambda's assignment is ignored, so VSTHRD002 is incorrectly suppressed even though the new task can block. Track writes from delegates known to execute here, or conservatively invalidate the proof when a captured task may be written.
static bool DescendIntoChildren(SyntaxNode node) => node is not AnonymousFunctionExpressionSyntax and not LocalFunctionStatementSyntax;
foreach (AssignmentExpressionSyntax assignment in node.DescendantNodes(DescendIntoChildren).OfType<AssignmentExpressionSyntax>())
{
if (assignment.SpanStart > afterPosition
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD002UseJtfRunAnalyzer.cs:263
- The continuation reassignment check has the same gap for tuple assignments:
GetSymbolInfo(assignment.Left, ...)does not identify the captured parameter in(t, other) = (Task.Run(() => 2), 0). A continuation can therefore reassigntand then readt.Resultwithout a diagnostic, contrary to the new reassignment-proof behavior. Inspect the individual assignment targets so deconstruction writes invalidate the completion proof.
foreach (AssignmentExpressionSyntax assignment in continuation.DescendantNodes().OfType<AssignmentExpressionSyntax>())
{
if (assignment.SpanStart < beforePosition
&& SymbolEqualityComparer.Default.Equals(context.SemanticModel.GetSymbolInfo(assignment.Left, context.CancellationToken).Symbol, taskParameter))
{
return true;
}
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD002UseJtfRunAnalyzer.cs:261
- The continuation-specific reassignment scan has the same ref-local alias gap. A continuation such as
t => { ref Task<int> alias = ref t; alias = Task.Run(...); return t.Result; }still matches the completed continuation parameter and suppresses the diagnostic, because neither scan recognizes the alias write as a write tot.
foreach (AssignmentExpressionSyntax assignment in continuation.DescendantNodes().OfType<AssignmentExpressionSyntax>())
{
if (assignment.SpanStart < beforePosition
&& SymbolEqualityComparer.Default.Equals(context.SemanticModel.GetSymbolInfo(assignment.Left, context.CancellationToken).Symbol, taskParameter))
{
src/Microsoft.VisualStudio.Threading.Analyzers.CodeFixes/VSTHRD002UseJtfRunCodeFixWithAwait.cs:167
- The generic parent-member fallback below this changed
Waitmatcher still offers a fix for a direct awaiter variable call such asTaskAwaiter<int> awaiter = task.GetAwaiter(); awaiter.GetResult();.FindGetAwaiterReceivercorrectly returns null because there is noGetAwaiter()invocation in the call, but the fallback selectsawaiterand rewrites it toawait awaiter;TaskAwaiteris an awaiter, not an awaitable, so the resulting code does not compile. Restrict that fallback to the knownResultproperties or otherwise validate that the selected receiver is awaitable.
else if (FindInstanceWaitReceiver(parentInvocation) is object)
{
// This method will not return null for the provided 'target' argument
transform = NullableHelpers.AsNonNullReturnUnchecked<ExpressionSyntax, CancellationToken, ExpressionSyntax>(FindInstanceWaitReceiver);
target = parentInvocation!;
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.
Suppressed comments (7)
Previously missed (1) — in code that hasn't changed since the last review.
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:453
- This write analysis only compares assignment targets with
taskSymbolitself (plusref/outarguments), so it misses ref-local aliases. For example,ref Task<int> alias = ref task; await task; alias = Task.Run(() => 2); _ = task.Result;writes the originaltaskstorage, but the assignment resolves toalias, so the prior await proof is incorrectly retained and VSTHRD002 is suppressed on an incomplete task. Track ref-local aliases or conservatively invalidate completion proofs when a ref alias is written, and add a regression test.
foreach (AssignmentExpressionSyntax assignment in node.DescendantNodesAndSelf(DescendIntoChildren).OfType<AssignmentExpressionSyntax>())
{
if (assignment.SpanStart > afterPosition
&& assignment.SpanStart < beforePosition
&& IsAssignmentToTask(context, assignment.Left, taskSymbol))
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:398
- The conditional-node check treats every await nested in a logical
&&/||expression as conditionally executed. That misses a definite completion such asif (await task && condition) { }: the left operand is always evaluated, sotask.Resultafterward is safe, but this path is discarded and the analyzer still reports VSTHRD002. Make the short-circuit analysis position-aware so only awaits whose evaluation can be skipped are rejected.
IEnumerable<SyntaxNode> ancestorsWithinStatement = candidate.Ancestors().TakeWhile(node => node != statement);
bool isConditionallyExecuted = ancestorsWithinStatement.Any(
node => node is StatementSyntax or ConditionalExpressionSyntax or SwitchExpressionSyntax or ConditionalAccessExpressionSyntax
|| (node is BinaryExpressionSyntax binary
&& (binary.IsKind(SyntaxKind.LogicalAndExpression)
|| binary.IsKind(SyntaxKind.LogicalOrExpression)
|| binary.IsKind(SyntaxKind.CoalesceExpression))));
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:257
- The completion prover handles
&&when the condition is true and||when it is false, but not the safe dual case. For example,if (task.IsCompleted || task.IsCanceled) { _ = task.Result; }guarantees that the task is terminal on the true branch, yet this returns false and still reports VSTHRD002. Since both operands are recognized completion properties, extend the boolean proof (and cover it with a regression test).
if (conditionValue && binary.IsKind(SyntaxKind.LogicalAndExpression))
{
return ConditionProvesCompletion(context, binary.Left, taskSymbol, conditionValue: true)
|| ConditionProvesCompletion(context, binary.Right, taskSymbol, conditionValue: true);
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:396
- The conditional-execution check does not recognize a
WhenClauseSyntax. Anawait taskin a switchwhenguard is therefore treated as a definite await for the entire switch statement, even though the default or another case can skip that guard; a latertask.Resultis then incorrectly suppressed. Include pattern/when guards and other conditional clauses in this control-flow test.
IEnumerable<SyntaxNode> ancestorsWithinStatement = candidate.Ancestors().TakeWhile(node => node != statement);
bool isConditionallyExecuted = ancestorsWithinStatement.Any(
node => node is StatementSyntax or ConditionalExpressionSyntax or SwitchExpressionSyntax or ConditionalAccessExpressionSyntax
|| (node is BinaryExpressionSyntax binary
&& (binary.IsKind(SyntaxKind.LogicalAndExpression)
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:479
NestedFunctionMayReassignTaskonly searches functions nested inside the function containing the access. Thus writes in an enclosing method are ignored when a captured local is read inside a local/anonymous function: anasync voidlocal can awaittask, while its caller reassignstaskafter invoking it, and the subsequenttask.Resultuses the new incomplete task even though this proof suppresses the warning. Include enclosing-scope writes or avoid this suppression for captured locals.
private static bool NestedFunctionMayReassignTask(SyntaxNodeAnalysisContext context, SyntaxNode node, ISymbol taskSymbol)
{
SyntaxNode? containingFunction = node.Ancestors().FirstOrDefault(
ancestor => ancestor is AnonymousFunctionExpressionSyntax or LocalFunctionStatementSyntax or BaseMethodDeclarationSyntax);
return containingFunction?.DescendantNodes()
.Where(descendant => descendant is AnonymousFunctionExpressionSyntax or LocalFunctionStatementSyntax)
.Any(nestedFunction => MayReassignTask(context, nestedFunction, taskSymbol)) is true;
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD002UseJtfRunAnalyzer.cs:201
- This configured-method path does not exclude
nameofexpressions. Innameof(waiter.Join()), Roslyn still visits the innerwaiter.Join()invocation, so its symbol matches the configured method and VSTHRD002 is reported even though the call is not executed. Skip invocations with anameofinvocation ancestor before reporting (the normal member-access path already applies anameofcheck).
if (methodName is object && !CSharpCommonInterest.ShouldIgnoreContext(context))
{
ImmutableDictionary<string, string?> properties = ImmutableDictionary<string, string?>.Empty.Add("SuppressAwaitCodeFix", null);
context.ReportDiagnostic(Diagnostic.Create(Descriptor, methodName.GetLocation(), properties));
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD002UseJtfRunAnalyzer.cs:262
- The continuation reassignment scan has the same direct-symbol limitation:
IsAssignmentToParameterdoes not recognize a ref-local alias of the continuation parameter. A continuation can declareref Task<int> alias = ref t, assign throughalias, and then readt.Result; the antecedent proof is stale, but this path returns without reporting. Track or conservatively reject ref aliases here as well.
foreach (AssignmentExpressionSyntax assignment in continuation.DescendantNodes().OfType<AssignmentExpressionSyntax>())
{
if (assignment.SpanStart < beforePosition
&& IsAssignmentToParameter(context, assignment.Left, taskParameter))
{
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated no new comments.
Suppressed comments (7)
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:44
- The completion proof is unsound for a captured task used inside a nested
async voidlambda/local function.GetSymbolAndRefAliaseschooses the nearest function scope, and the later block walk stops at that boundary, so code such asasync void L() { await task; _ = task.Result; }can suppress VSTHRD002 even when the caller invokesL()and then reassignstaskbefore the awaited task completes; the await captured the old task butResultreads the new one. Account for writes in enclosing scopes for captured symbols, or disable this proof when the task is captured.
SyntaxNode searchRoot = node.AncestorsAndSelf().FirstOrDefault(
ancestor => ancestor is AnonymousFunctionExpressionSyntax
or LocalFunctionStatementSyntax
or BaseMethodDeclarationSyntax
or AccessorDeclarationSyntax) ?? node;
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:65
- The alias set is built only in the
initializedFrom -> localdirection. When the receiver is a ref local, this misses the original storage symbol:ref Task alias = ref task; await alias; task = Task.Run(...); _ = alias.Result;starts withalias, so the later assignment totaskis not seen and the await proof incorrectly suppresses the warning even thoughaliasnow reads the reassigned task. Build the ref-alias closure bidirectionally (for both declarations and= refassignments), while conservatively handling rebinding.
ISymbol? initializedFrom = context.SemanticModel.GetSymbolInfo(UnwrapParentheses(initializer), context.CancellationToken).Symbol;
if (initializedFrom is object && symbols.Contains(initializedFrom) && symbols.Add(local))
{
addedAlias = true;
}
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:596
IsSameTaskcompares only the singletaskSymbol, so completion proofs do not work through the ref aliases thatGetSymbolAndRefAliasesnow collects. For example,ref Task<int> alias = ref task; if (alias.IsCompleted) _ = task.Result;still reports VSTHRD002 (andawait alias; task.Resultis likewise missed), although both names refer to the same task storage. Use the alias set when matching guarded/awaited receivers.
private static bool IsAssignmentToTask(SyntaxNodeAnalysisContext context, ExpressionSyntax expression, IImmutableSet<ISymbol> taskSymbols)
{
expression = UnwrapParentheses(expression);
if (expression is TupleExpressionSyntax tuple)
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:207
HasTaskCompletedis used by everyCSharpCommonInterest.InspectMemberAccesscaller, including VSTHRD102 forJoinableTask.Join, but this guard only checks that the receiver is a local/parameter and never verifies it is aTask/ValueTask. Consequentlyif (jt.IsCompleted) { jt.Join(); }(wherejtis aJoinableTask) is treated as completed and the VSTHRD102 diagnostic is suppressed, even thoughJoinableTask.IsCompletedis not the Task completion proof this analyzer is implementing. Restrict this logic to Task receivers or to VSTHRD002.
private static bool HasTaskCompleted(SyntaxNodeAnalysisContext context, MemberAccessExpressionSyntax memberAccessSyntax)
{
ISymbol? taskSymbol = context.SemanticModel.GetSymbolInfo(GetTaskReceiver(memberAccessSyntax), context.CancellationToken).Symbol;
if (taskSymbol is not ILocalSymbol and not IParameterSymbol)
{
return false;
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD002UseJtfRunAnalyzer.cs:262
- For a direct access in the continuation,
beforePositionis the access position, so writes inside a local function declared later are ignored. Local functions are in scope before their declaration, soReplace(); _ = t.Result; void Replace() => t = Task.Run(...);can reassigntbefore the result is read. This early return bypasses the common nested-function check and suppresses a real VSTHRD002 diagnostic; account for writes reachable through nested/local functions before suppressing.
IParameterSymbol taskParameter)
{
bool accessIsNested = memberAccess.Ancestors().TakeWhile(node => node != continuation).Any(node => node is AnonymousFunctionExpressionSyntax or LocalFunctionStatementSyntax);
int beforePosition = accessIsNested ? continuation.Span.End + 1 : memberAccess.SpanStart;
ImmutableHashSet<ISymbol> taskSymbols = CSharpCommonInterest.GetSymbolAndRefAliases(context, continuation, taskParameter);
foreach (AssignmentExpressionSyntax assignment in continuation.DescendantNodes().OfType<AssignmentExpressionSyntax>())
{
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD002UseJtfRunAnalyzer.cs:153
- The continuation fast path still requires the receiver symbol to equal the parameter symbol, so a ref-local alias inside the continuation is treated as unrelated and falls through to a warning. For example,
task.ContinueWith(t => { ref Task<int> alias = ref t; _ = alias.Result; })reports even though the alias refers to the completed continuation parameter. Match the receiver against the parameter's ref-alias set before invoking the general analyzer.
ParameterSyntax? firstParameter = GetFirstParameter(anonymousFunctionSyntax);
if (firstParameter is object
&& context.SemanticModel.GetDeclaredSymbol(firstParameter, context.CancellationToken) is IParameterSymbol completedTask
&& SymbolEqualityComparer.Default.Equals(GetTaskReceiverSymbol(context, memberAccessSyntax), completedTask)
&& !IsTaskReassignedInContinuation(context, anonymousFunctionSyntax, memberAccessSyntax, completedTask))
src/Microsoft.VisualStudio.Threading.Analyzers.CodeFixes/VSTHRD002UseJtfRunCodeFixWithAwait.cs:135
- This matcher checks only the
GetAwaitermember name, not its argument list. A valid customGetAwaiter(int)can return aTaskAwaiter, causing VSTHRD002 to reportvalue.GetAwaiter(1).GetResult(); the code fix then rewrites it toawait value, which does not compile because the await pattern requires a parameterlessGetAwaiter(). Require the matchedGetAwaiterinvocation to have zero arguments (and otherwise do not offer this fix).
var getAwaiterAccess = (getAwaiterInvocationExpression as InvocationExpressionSyntax)?.Expression as MemberAccessExpressionSyntax;
return getAwaiterAccess?.Name.Identifier.ValueText == "GetAwaiter" ? getAwaiterAccess.Expression : null;
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 9 changed files in this pull request and generated no new comments.
Suppressed comments (5)
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:63
- The alias set is used for write invalidation, but completion proofs still compare conditions to the single
taskSymbol(and the set itself only grows from a known source to ref locals). Thusref Task<int> alias = ref task; if (alias.IsCompleted) { _ = task.Result; }is still reported even though both names refer to the same storage; the reverse direction (if (task.IsCompleted) { _ = alias.Result; }) has the same gap. Make the ref-alias set an equivalence closure and use it in the completion/await comparisons as well.
ISymbol? initializedFrom = context.SemanticModel.GetSymbolInfo(UnwrapParentheses(initializer), context.CancellationToken).Symbol;
if (initializedFrom is object
&& (symbols.Contains(initializedFrom) || symbols.Contains(local))
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:433
- This helper does not recurse into an
ifstatement when it is asked whether a guard body definitely awaits the task; it only handles a direct await or a block's direct statements. Therefore a valid proof such asif (!task.IsCompleted) { if (condition) await task; else await task; }still reportstask.Resultafterward, even though every path through the negative branch awaits the task. Recurse through nested conditional statements (and their branches) when determining definite completion.
return ConditionProvesCompletion(context, ifStatement.Condition, taskSymbols, conditionValue: false)
&& StatementDefinitelyAwaitsTask(context, ifStatement.Statement, taskSymbols);
}
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:205
- The completion proof treats any local/parameter receiver as a completed task after peeling a
GetAwaitercall, without verifying that the receiver isTask/ValueTask. A custom awaitable can returnTaskAwaiterfromGetAwaiter; afterawait wrapper, a laterwrapper.GetAwaiter().GetResult()may obtain a fresh, incomplete operation, but this path is suppressed as completed. Restrict this proof to the supported task types (and validate the corresponding awaiter/configuration methods semantically).
}
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD002UseJtfRunAnalyzer.cs:258
- For a direct access,
beforePositionstops at theResult/Waitexpression, so writes inside a local function declared later are ignored. A continuation can invoke that local function before reading the result, e.g.task.ContinueWith(t => { Reassign(); _ = t.Result; void Reassign() { t = Task.Run(() => 6); } });; the parameter is then no longer the completed antecedent, but this path suppresses the diagnostic. Account for callable nested-function writes with control-flow (or conservatively treat them as reassignment) instead of relying only on source order.
SyntaxNodeAnalysisContext context,
AnonymousFunctionExpressionSyntax continuation,
src/Microsoft.VisualStudio.Threading.Analyzers.CodeFixes/VSTHRD002UseJtfRunCodeFixWithAwait.cs:135
FindGetAwaiterReceiverassumes everyX.GetAwaiter(...).GetResult()chain can be rewritten by awaitingX. For a qualified static or unrelated instance factory such asAwaiterFactory.GetAwaiter(task).GetResult(), this returnsAwaiterFactoryand producesawait AwaiterFactory, which does not compile. The matcher needs semantic validation that the call is the direct/reduced-extension awaiter form, or it should decline the fix for other call shapes.
var getAwaiterInvocation = getAwaiterInvocationExpression as InvocationExpressionSyntax;
var getAwaiterAccess = getAwaiterInvocation?.Expression as MemberAccessExpressionSyntax;
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.
Suppressed comments (6)
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:102
- A ref local whose target is not represented by a symbol is silently omitted from the alias map. For example,
ref Task<int> alias = ref tasks[0]leavesaliasas an independent task symbol; a latertasks[0] = Task.Run(...)is also not recognized byIsAssignmentToTask, soif (alias.IsCompleted) { tasks[0] = ...; _ = alias.Result; }is incorrectly suppressed. Track reference locations such as array elements, or conservatively invalidate/refuse completion proofs for unresolved ref targets.
ExpressionSyntax initializer = variable.Initializer.Value is RefExpressionSyntax refInitializer
? refInitializer.Expression
: variable.Initializer.Value;
if (context.SemanticModel.GetSymbolInfo(UnwrapParentheses(initializer), context.CancellationToken).Symbol is ISymbol initializedFrom)
{
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:337
- The continuation path only adds
Potentialaliases from the enclosing continuation, butGetSymbolAndRefAliasesdeliberately does not descend into nested lambdas or local functions. Consequently, a nested local function can create a ref alias totand write through it before a top-levelt.Resultaccess without that alias enteringpotentialTaskSymbols;IsTaskReassignedInContinuationthen misses the write and suppresses VSTHRD002 even though the continuation parameter may now reference an incomplete task. Gather aliases recursively for nested functions (or conservatively invalidate when such a function can write through a ref alias) before accepting this completion proof.
ParameterSyntax? firstParameter = anonymousFunction switch
{
SimpleLambdaExpressionSyntax lambda => lambda.Parameter,
ParenthesizedLambdaExpressionSyntax lambda => lambda.ParameterList.Parameters.FirstOrDefault(),
AnonymousMethodExpressionSyntax anonymousMethod => anonymousMethod.ParameterList?.Parameters.FirstOrDefault(),
_ => null,
};
if (firstParameter is null
|| context.SemanticModel.GetDeclaredSymbol(firstParameter, context.CancellationToken) is not IParameterSymbol completedTask)
{
continue;
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:860
- Grouping
DoStatementSyntaxwith the zero-or-more loop forms makesTryGetAwaitExpressionreject everydostatement. As a result,do { await task; } while (condition);followed bytask.Resultstill reports VSTHRD002/VSTHRD103 even though the body (unlike the condition) executes at least once and definitively awaits this task. Handle the do-body as a definite execution path while retaining the conservative behavior for an await only in the loop condition.
{
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD002UseJtfRunAnalyzer.cs:68
- The configured-method analysis is still registered only inside the
taskSymbolcheck below. A compilation that does not referenceSystem.Threading.Tasks.Tasktherefore never analyzes anyvs-threading.SyncBlockingMethods.txtentry, even though configured blockers may be arbitrary project-defined instance, static, or extension methods. Register the code-block actions independently of the built-in Task symbol (the built-in matcher can simply find no matches).
ImmutableArray<CommonInterest.QualifiedMember> configuredSyncBlockingMethods = CommonInterest.ReadMethods(
compilationContext.Options,
new Regex(@"^vs-threading\.SyncBlockingMethods(\..*)?.txt$", RegexOptions.IgnoreCase | RegexOptions.Singleline),
compilationContext.CancellationToken).ToImmutableArray();
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD002UseJtfRunAnalyzer.cs:161
- This suppresses VSTHRD002 whenever an async-suffixed candidate exists, assuming VSTHRD103 will report instead. That assumption is not valid when VSTHRD103 is disabled or suppressed at the call site (for example,
#pragma warning disable VSTHRD103): the configured blocker then produces no diagnostic at all, despite not suppressing VSTHRD002. Avoid dropping this diagnostic unless the corresponding VSTHRD103 diagnostic is actually active, or preserve the configured VSTHRD002 report when it is suppressed.
}
bool isBuiltInSyncBlockingMethod = CommonInterest.ProblematicSyncBlockingMethods.Any(
method => method.Method.IsMatch(invokedMethod) || method.Method.IsMatch(methodDefinition));
bool coveredByVSTHRD103 = !methodsExcludedFromVSTHRD103.Contains(invokedMethod)
&& !methodsExcludedFromVSTHRD103.Contains(methodDefinition)
src/Microsoft.VisualStudio.Threading.Analyzers.CodeFixes/VSTHRD002UseJtfRunCodeFixWithAwait.cs:106
IsRefLikeTypedoes not cover pointer or function-pointer types. An unsafe method such asint* F(Task<int> task) { return task.Result == 0 ? null : null; }passes this gate, butMakeMethodAsyncgeneratesTask<int*>, which is not a legal generic type (and pointer locals can likewise cross the inserted await). Reject pointer/function-pointer signatures and locals, or otherwise withhold the fix for these unsafe contexts.
return true;
}
bool changesContract = !methodSymbol.HasAsyncCompatibleReturnType();
if (!changesContract)
{
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated 4 comments.
Suppressed comments (6)
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:1044
- The invalidation scan only treats explicit
ref/outargument syntax as a possible write. Athis ref Task<T>extension receiver is passed by reference without areftoken at the call site, so a call such asif (task.IsCompleted) { task.Replace(); task.Result; }can replace the task storage without being detected and the result access is incorrectly suppressed. Inspect the invoked method's receiver parameter/ref kind (including reduced extension methods), or conservatively invalidate completion proofs for ref receivers.
{
awaitedExpression = UnwrapParentheses(configureAwaitAccess.Expression);
}
if (IsOneOfSymbols(context, awaitedExpression, taskSymbols))
{
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:350
- For a continuation access nested more than one lambda deep, this only adds potential aliases found from the continuation root, and
GetSymbolAndRefAliasesdeliberately does not descend into nested lambdas. An alias declared in an intermediate lambda is therefore absent:ref Task<int> alias = ref t;in an outer nested lambda, followed byalias = Task.Run(...); return t.Result;in an inner lambda, is not recognized as a write and the completed-continuation suppression is incorrectly applied. Merge potential aliases from every enclosing nested-function scope while preserving the access-point definite set.
return HasTaskCompleted(context, taskReceiver, memberAccessSyntax);
}
/// <summary>
/// Determines whether a task-like expression is provably complete at a syntax node.
/// </summary>
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:1138
MayAliasTaskStoragedoes not account for the receiver parameter of a reduced ref-returning extension method. Athis ref Task<T>extension such asstatic ref Task<T> GetReference(this ref Task<T> task) => ref taskhas its receiver inReducedFrom.Parameters, while the reduced symbol's parameters omit it; consequentlytask.GetReference() = replacementis not recognized as a write and a guardedtask.Resultcan be incorrectly suppressed. Include the reduced receiver when tracing ref-return aliases.
return true;
}
return nestedFunction.DescendantNodes(
descendant => descendant == nestedFunction
|| descendant is not AnonymousFunctionExpressionSyntax and not LocalFunctionStatementSyntax)
.Where(descendant => descendant is AnonymousFunctionExpressionSyntax or LocalFunctionStatementSyntax)
.Any(descendant => FunctionMayReassignTask(descendant, nestedSymbols));
}
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD002UseJtfRunAnalyzer.cs:232
- The configured-method copy of the matcher has the same positional-binding bug: a
Join(int, string)call can be considered covered byJoinAsync(string, int)because the types are matched as an unordered set. That suppresses VSTHRD002 even though the async alternative cannot be called with the original arguments. Compare corresponding parameter positions (and ref kinds), allowing only callable optional/paramstrailing arguments.
LocalFunctionStatementSyntax localFunction => context.SemanticModel.GetDeclaredSymbol(localFunction, context.CancellationToken),
MethodDeclarationSyntax method => context.SemanticModel.GetDeclaredSymbol(method, context.CancellationToken),
_ => null,
};
return containingMethod?.HasAsyncCompatibleReturnType() is true;
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD103UseAsyncOptionAnalyzer.cs:220
- This matching is still unordered:
remainingCandidateTypes.FindIndexallows a syncJoin(int, string)to matchJoinAsync(string, int)because each type appears once, even though the same positional arguments cannot bind to the async method. VSTHRD103 can therefore suggest a non-compiling replacement, and VSTHRD002 can treat the configured blocker as covered. Match parameters by ordinal (including ref kind), and only allow omitted optional/paramstrailing parameters.
break;
case MethodDeclarationSyntax methodDecl:
methodSymbol = context.SemanticModel.GetDeclaredSymbol(methodDecl, context.CancellationToken);
break;
default:
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD103UseAsyncOptionAnalyzer.cs:232
- Parameter type equality also rejects valid generic alternatives:
LookupSymbolsreturns the unconstructedFooAsync<T>definition, while the invokedFoo<T>(T)symbol carries a different type-parameter symbol (or an inferred concrete type), soSymbolEqualityComparercannot match the correspondingTparameters. A genericFoo/FooAsyncpair with parameters is therefore missed by VSTHRD103; compare substituted types or match type parameters by ordinal/constraint shape.
return methodSymbol?.HasAsyncCompatibleReturnType() is true;
}
private bool InspectMemberAccess(
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated no new comments.
Suppressed comments (6)
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:703
MayHaveAwaitedTaskBeforeonly adds copies whose initializer/assignment symbol is the originalValueTasksymbol. A configured awaitable is produced by a method invocation, sovar configured = task.ConfigureAwait(false); await configured; if (task.IsCompletedSuccessfully) return task.Result;is treated as if theValueTaskwas never consumed. Awaiting the configured awaitable can consume anIValueTaskSource-backedValueTask, making this laterResultaccess unsafe; trackConfigureAwait-derived aliases/awaits or conservatively retain the diagnostic.
candidateStatement = containingBlock;
}
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:1058
- This proof only recognizes direct
Task.WhenAllarguments and inline array initializers. A common equivalent such asTask[] tasks = { task }; await Task.WhenAll(tasks); task.Result;is still reported even though awaitingWhenAllproves thattaskcompleted. Please track the collection argument (including intervening writes), or otherwise cover this overload before claimingTask.WhenAllas a completion proof.
private static bool AwaitCompletesTask(
SyntaxNodeAnalysisContext context,
AwaitExpressionSyntax awaitExpression,
IImmutableSet<ISymbol> taskSymbols)
{
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:714
- This only records assignment destinations that are
ILocalSymbols, so aValueTaskcopy held in a parameter is missed. For example, aftercopy = task; await copy;,if (task.IsCompletedSuccessfully) return task.Result;is treated as safe even though awaitingcopyconsumed the originalValueTaskandResultmay throw. Track parameter destinations (and other copy forms) here before deciding that a guardedValueTask.Resultis safe.
{
var taskAndCopySymbols = new HashSet<ISymbol>(taskSymbols, SymbolEqualityComparer.Default);
bool IsTaskOrCopy(ExpressionSyntax expression)
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:1013
- The conditional-await detection does not account for
??=. Anawaiton the right-hand side of a coalescing assignment only runs when the left-hand side is null, so code such asother ??= await task; task.Result;can suppress VSTHRD002 even whentaskwas never awaited. Treat theCoalesceAssignmentExpressionright-hand side as conditionally executed (and add a regression test).
int beforePosition,
[NotNullWhen(true)] out AwaitExpressionSyntax? awaitExpression)
{
static bool DescendIntoChildren(SyntaxNode node) => node is not AnonymousFunctionExpressionSyntax and not LocalFunctionStatementSyntax;
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD002UseJtfRunAnalyzer.cs:141
- This built-in path only passes
MemberAccessExpressionSyntaxtoInspectMemberAccess. Fortask?.Wait()the invocation expression is aMemberBindingExpressionSyntax, so this call is skipped and the separateSimpleMemberAccessExpressionaction cannot see it either. In a synchronous method VSTHRD103 does not analyze the call, leaving a built-in blocking wait undiagnosed; conditional-access invocations need to be handled here as well.
InspectMemberAccess(
context,
invocationExpressionSyntax.Expression as MemberAccessExpressionSyntax,
CommonInterest.ProblematicSyncBlockingMethods);
src/Microsoft.VisualStudio.Threading.Analyzers.CodeFixes/VSTHRD002UseJtfRunCodeFixWithAwait.cs:112
- This conversion check does not account for name collisions introduced by
MakeMethodAsync. A legal class can containint F(Task task)andTask FAsync(Task task); convertingFchanges its return type and then unconditionally renames it toFAsync, producing duplicate members and a compiler error even though this gate returnstrue. The same collision can occur when a caller is renamed toCallerAsync; reject or otherwise handle existing members with the post-rename signature before registering the fix.
if (!CanChangeMethodContract(method, methodSymbol)
|| await HasMethodGroupReferenceAsync(document.Project.Solution, methodSymbol, cancellationToken).ConfigureAwait(false))
{
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated no new comments.
Suppressed comments (9)
Previously missed (1) — in code that hasn't changed since the last review.
src/Microsoft.VisualStudio.Threading.Analyzers.CodeFixes/VSTHRD002UseJtfRunCodeFixWithAwait.cs:53
- The new eligibility checks still allow a diagnostic on a nested
Resultaccess such asreturn task.Result.Length;.TryFindNodeAtSourcetargetstask.Result, and the action then replaces it with a bareAwaitExpression, producingreturn await task.Length;rather than(await task).Length(and similarly for indexing or chained calls). Either parenthesize the awaited replacement whenever it remains a subexpression or reject these shapes before registering the fix.
|| IsAwaitForbiddenAt(target, containingMethod)
|| !await CanConvertToAsyncAsync(context.Document, semanticModel, containingMethod, context.CancellationToken).ConfigureAwait(false)
|| semanticModel.GetDiagnostics(target.FullSpan, context.CancellationToken).Any(d => d.Severity == DiagnosticSeverity.Error)
|| !CanUseAwaitCodeFix(semanticModel, target, context.CancellationToken))
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:577
GetSymbolAndRefAliasesonly relates ref locals totaskSymbol; it never models a separaterefparameter. Consequently, invoid F(Task<int> task, ref Task<int> alias) { if (task.IsCompleted) { alias = Task.Run(...); return task.Result; } },aliasis absent frompotentialTaskSymbols, so the write is missed and the completion guard suppresses a warning even when the caller passes the same storage for both parameters. Treat writes through ref parameters as potentially aliasing the tracked task (or conservatively refuse this proof) and add a regression test for an ordinary task parameter plus a ref alias.
}
receiver = UnwrapParentheses(receiver);
return receiver is InvocationExpressionSyntax configureAwaitInvocation
&& IsSupportedConfigureAwaitInvocation(context, configureAwaitInvocation);
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:593
- This branch only invalidates a
ValueTaskcompletion proof whenMayHaveAwaitedTaskBeforefinds anawait. A prior consuming synchronous access is omitted: afterif (task.IsCompletedSuccessfully) { task.GetAwaiter().GetResult(); return task.Result; }, the secondResultis still suppressed even though anIValueTaskSource-backed ValueTask may only be consumed once. Track priorGetResult/Resultconsumption (including copies) or conservatively retain the diagnostic for it.
|| (type?.Name == nameof(ValueTask) && type.BelongsToNamespace(Namespaces.SystemThreadingTasks));
private static bool HasTaskCompletedCore(
SyntaxNodeAnalysisContext context,
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:592
- For
ValueTask, this branch relies onMayHaveAwaitedTaskBeforeto detect prior consumption, but that routine only recognizes the built-inConfigureAwaitshape. A project extension such asstatic ValueTask<int> ConfigureAwait(this ValueTask<int> value, string mode) => value;is awaited, thenif (value.IsCompletedSuccessfully) value.Resultis incorrectly suppressed even though awaiting the extension consumes the originalValueTaskand a subsequentResultis unsafe. Any await expression that can alias the trackedValueTask(or at least an awaitable-returning extension receiver) must invalidate this proof.
|| (type?.Name == nameof(ValueTask) && type.BelongsToNamespace(Namespaces.SystemThreadingTasks));
private static bool HasTaskCompletedCore(
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:592
- The ValueTask consumption check also excludes nested local functions/lambdas from
MayHaveAwaitedTaskBefore. Thusasync Task ConsumeAsync() { await task; }, followed byawait ConsumeAsync(); if (task.IsCompletedSuccessfully) return task.Result;, is treated as safe even though the nested function awaited (and may have consumed) the capturedValueTask. The analysis must propagate prior awaits through invoked nested functions, or conservatively invalidate the ValueTask proof when such a call can capture it.
|| (type?.Name == nameof(ValueTask) && type.BelongsToNamespace(Namespaces.SystemThreadingTasks));
private static bool HasTaskCompletedCore(
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:364
- Requiring the lambda's immediate parent to be an
ArgumentSyntaxmisses valid continuation callbacks wrapped in parentheses or a cast, such astask.ContinueWith((t => t.Result)). The receiver is still the first argument ofTask.ContinueWithand is guaranteed complete, but this path falls through and reports VSTHRD002. Find the containing argument (while keeping the first-argument check) instead of requiring the direct parent.
if (invocation.Expression is MemberBindingExpressionSyntax
&& invocation.FirstAncestorOrSelf<ConditionalAccessExpressionSyntax>() is { } conditionalAccess)
{
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:1215
- This positional pairing assumes the invocation has no receiver parameter. A ref-returning extension invoked with static syntax includes its
thisparameter inmethod.Parameters, so a call such asReplace(ref GetRef(owner, ref task))is not recognized as writing task storage. A completion proof can then incorrectly suppress the latertask.Result; bind arguments to their actualIParameterSymbol(or otherwise account for the extension receiver) before checkingRefKind.
IImmutableSet<ISymbol> taskSymbols,
int afterPosition,
int beforePosition)
{
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:398
- Only the outer continuation aliases'
Potentialset is merged here. A definitely bound ref local from that scope is therefore absent fromtaskSymbolswhen the nested lambda reads through it; for example,task.ContinueWith(t => { ref Task<int> alias = ref t; Action use = () => alias.Result; use(); })still reports VSTHRD002 even though the callback runs aftertcompletes. Merge the outerDefinitealiases intotaskSymbolswhile retainingPotentialfor deferred-write invalidation, and add a regression for a nested read through such an alias.
/// </summary>
internal static bool HasTaskCompleted(SyntaxNodeAnalysisContext context, ExpressionSyntax taskReceiver, SyntaxNode accessSyntax)
=> HasTaskCompletedInContinuation(context, taskReceiver, accessSyntax)
|| HasTaskCompletedCore(context, taskReceiver, accessSyntax);
src/Microsoft.VisualStudio.Threading.Analyzers.CodeFixes/VSTHRD002UseJtfRunCodeFixWithAwait.cs:267
- This special case only rejects a returned invocation when the caller's async-compatible return type has
Arity: 0. A synchronous callee can return a derivedTask<T>and be returned from aTask<T>caller; after conversion the callee returnsTask<DerivedTask<T>>, and the caller rewrite becomesasync Task<T> => await ..., which cannot produceTand leaves a compiler error. Reject this return-position case for generic async-compatible callers too, or verify the awaited result is assignable to the caller's result type.
if (callingMethodSymbol.ReturnType is INamedTypeSymbol { Arity: 0 } nonGenericReturnType
&& nonGenericReturnType.IsAsyncCompatibleReturnType()
&& invocation.FirstAncestorOrSelf<ReturnStatementSyntax>() is { Expression: { } returnExpression }
&& returnExpression.FullSpan.Contains(invocation.Span))
{
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated 3 comments.
Suppressed comments (4)
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:795
- This removal is unsound when the copy is reassigned from an expression that can still select the tracked
ValueTask. For example, aftercopy = condition ? other : task;,IsTaskOrCopyreturns false and the assignment is considered definitely before the await, socopyis removed;await copycan nevertheless consumetask, butMayHaveAwaitedTaskBeforethen misses that path and may suppress the latertask.Resultdiagnostic. Keep potential aliases for conditional/unknown RHS expressions and remove a copy only when it is proven not to alias the tracked task.
else if (IsDefinitelyExecutedBefore(assignment, awaitExpression))
{
taskAndCopySymbols.Remove(assignedSymbol);
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:134
- The ref-local declaration path recognizes a by-ref-returning method, but this rebinding path treats the method symbol itself as the target and never follows its
refarguments. Afteralias = ref GetTaskRef(ref task),aliasis not included as a possible alias oftask; a subsequentalias = replacementcan therefore be missed and a guardedtask.Resultcan be incorrectly treated as safe. Reuse the ref-return analysis for rebindings and preserve the alias's potential-only status unless its target is proven.
HashSet<ISymbol>? assignedTargets = assignedFrom is object
? GetRefTargets(assignedFrom)
: MayAliasTaskStorage(context, assignedExpression, ImmutableHashSet.Create<ISymbol>(SymbolEqualityComparer.Default, symbol))
? new HashSet<ISymbol>(SymbolEqualityComparer.Default) { symbol }
: null;
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:408
- The continuation proof requires the callback lambda to be the first syntactic argument. C# permits named arguments out of source order, so a valid stateful call such as
ContinueWith(state: state, continuationAction: (t, _) => t.Result)puts the callback second even though it is bound to parameter 0; this code then emits an unnecessary blocking diagnostic. Use the boundIArgumentOperation.Parameter.Ordinalrather than the argument-list position.
if (anonymousFunction.Parent is not ArgumentSyntax anonymousFunctionArgument
|| anonymousFunctionArgument.Parent?.Parent is not InvocationExpressionSyntax continuationInvocation
|| continuationInvocation.ArgumentList.Arguments.FirstOrDefault() != anonymousFunctionArgument)
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/VSTHRD002UseJtfRunAnalyzer.cs:175
- The configured-method path is skipped whenever VSTHRD103 could cover the call. If a project enables VSTHRD002 with
SyncBlockingMethods.txtbut disables VSTHRD103 (or sets it tonone), a configured blocker that has an applicableJoinAsync-style alternative produces no diagnostic at all. Coordinate this suppression with the actual VSTHRD103 configuration, or keep a VSTHRD002 diagnostic when that companion analyzer is disabled.
bool coveredByVSTHRD103 = !methodsExcludedFromVSTHRD103.Contains(invokedMethod)
&& !methodsExcludedFromVSTHRD103.Contains(methodDefinition)
&& !invokedMethod.Name.EndsWith(VSTHRD200UseAsyncNamingConventionAnalyzer.MandatoryAsyncSuffix, StringComparison.CurrentCulture)
&& !invokedMethod.HasAsyncCompatibleReturnType()
&& IsInTaskReturningMethodOrDelegate(context)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.
Suppressed comments (10)
docfx/analyzers/VSTHRD002.md:46
- This wording does not distinguish
TaskfromValueTask: awaiting aValueTaskcan consume it, and the analyzer intentionally still reports a laterValueTask.Resultaccess (covered byAwaitedValueTaskCompletionGuardStillGeneratesWarning). Clarify that the direct-await/Task.WhenAllproof applies to reusableTaskvalues and document theValueTaskconsumption caveat.
Accessing a task's result is not reported when the analyzer can prove the task has completed.
Recognized proofs include awaiting the task (directly or through `Task.WhenAll`), guarding the
access with a completion property such as `IsCompletedSuccessfully`, and awaiting the task in
the negative branch of such a guard.
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:845
- Local functions may be called before their declaration, but this filter excludes declarations after the access. For example,
await ConsumeAsync(); if (task.IsCompletedSuccessfully) return task.Result; async Task ConsumeAsync() { await task; }consumes aValueTaskbefore the guarded access, yet the laterResultis treated as safe. Examine all local functions and keep the existing pre-access invocation check instead of filtering by declaration position.
foreach (LocalFunctionStatementSyntax localFunction in searchRoot.DescendantNodes()
.OfType<LocalFunctionStatementSyntax>()
.Where(localFunction => localFunction.SpanStart < accessSyntax.SpanStart))
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:868
- Consumption tracking only recognizes delegate variables initialized with anonymous functions. A method group to a local async function is missed:
async Task ConsumeAsync() { await task; }, followed byFunc<Task> consume = ConsumeAsync; await consume();, can consume theValueTaskbefore the completion guard, but the guard can still suppresstask.Result. Track method-group targets when their delegates are invoked (or conservatively invalidate the proof).
foreach (VariableDeclaratorSyntax delegateVariable in searchRoot.DescendantNodes(DescendIntoChildren)
.OfType<VariableDeclaratorSyntax>()
.Where(variable => variable.SpanStart < accessSyntax.SpanStart
&& variable.Initializer?.Value is AnonymousFunctionExpressionSyntax))
{
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:135
- The ref-local rebinding path treats a ref-returning invocation as an alias to the method symbol itself, unlike the declaration path above which maps ref-returning methods to the tracked storage symbol. For example, after
ref Task<int> alias = ref task; alias = ref GetReference(ref task);, a later write throughaliasis not included inpotentialTaskSymbols, soif (task.IsCompleted) { alias = replacement; return task.Result; }can incorrectly suppress VSTHRD002 even thoughtaskwas replaced. Follow the ref-return target (and retain its potential-alias state) here before callingGetRefTargetson ordinary symbols.
ISymbol? assignedFrom = context.SemanticModel.GetSymbolInfo(assignedExpression, context.CancellationToken).Symbol;
HashSet<ISymbol>? assignedTargets = assignedFrom is object
? GetRefTargets(assignedFrom)
: MayAliasTaskStorage(context, assignedExpression, ImmutableHashSet.Create<ISymbol>(SymbolEqualityComparer.Default, symbol))
? new HashSet<ISymbol>(SymbolEqualityComparer.Default) { symbol }
: null;
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:1347
- The
Task.WhenAllproof only checks direct writes/ref-or-out arguments to the local array; it does not account for the array escaping to an ordinary call. A method such asReplace(Task[] tasks, Task replacement) => tasks[0] = replacementcan replace the tracked element beforeawait Task.WhenAll(tasks), after whichtask.Resultmay still block, but this code will suppress the diagnostic. Treat mutable collection escapes (including ordinary aliases) as invalidating the proof, or otherwise analyze the callee's possible element writes.
if (MayReassignTask(context, searchRoot, taskSymbols, initializer.Span.End, awaitExpression.SpanStart)
|| MayReassignTask(context, searchRoot, collectionSymbol, initializer.Span.End, awaitExpression.SpanStart)
|| NestedFunctionMayReassignTask(context, awaitExpression, taskSymbols)
|| NestedFunctionMayReassignTask(context, awaitExpression, collectionSymbol))
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:981
- This treats every preceding
LabeledStatementSyntaxas a possible control-flow bypass, even when nogototargets the label. For example,await task; label: return task.Result;has no path to the result that skips the await, butContainsPotentialControlFlowBypassstill prevents the completion proof and reports a warning. Only labels with an actual incoming jump (or at leastGotoStatementSyntaxhere) should invalidate the proof.
&& node is GotoStatementSyntax or LabeledStatementSyntax);
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:710
- When the result access is inside an enclosing control-flow statement, this walk only examines statements before that outer statement and never evaluates whether the outer statement itself contains a definite await. Consequently
if (await task) { return task.Result; }(and aswitch (await task)arm) is reported even though the await condition must execute before the body. Check the current enclosing statement for a completion proof before ascending past it, while retaining the intervening-write checks.
StatementSyntax? outerStatement = containingStatement.Ancestors().OfType<StatementSyntax>()
.FirstOrDefault(statement => statement.Parent is BlockSyntax or SwitchSectionSyntax);
if (outerStatement is null)
{
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:1360
- The local-array proof only rejects element writes whose receiver symbol is the exact tracked collection symbol. A normal alias is not tracked, so
Task[] alias = tasks; alias[0] = replacement; await Task.WhenAll(tasks); return task.Result;can incorrectly be treated as provingtaskcomplete even though the array no longer contains it. Track mutable collection aliases or conservatively invalidate this proof when the array may be modified.
bool IsCollectionElement(ExpressionSyntax expression)
=> UnwrapParentheses(expression) is ElementAccessExpressionSyntax elementAccess
&& IsOneOfSymbols(context, elementAccess.Expression, collectionSymbol);
return !searchRoot.DescendantNodes()
.Where(node => node.SpanStart > initializer.Span.End && node.SpanStart < awaitExpression.SpanStart)
.Any(node => (node is AssignmentExpressionSyntax assignment && IsCollectionElement(assignment.Left))
|| (node is ArgumentSyntax argument
&& (argument.RefKindKeyword.IsKind(SyntaxKind.RefKeyword) || argument.RefKindKeyword.IsKind(SyntaxKind.OutKeyword))
&& IsCollectionElement(argument.Expression)));
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:965
AwaitMayConsumeValueTaskdoes not recognize conditional awaited expressions. Inawait (condition ? task : other), thetaskValueTask may be consumed, but the laterIsCompletedSuccessfullyguard is currently allowed to suppresstask.Resultbecause the conditional expression matches none of the checks below. Treat either conditional branch as a possible consumption (or fail closed for unsupported awaited expressions).
private static bool AwaitMayConsumeValueTask(
SyntaxNodeAnalysisContext context,
AwaitExpressionSyntax awaitExpression,
IImmutableSet<ISymbol> taskSymbols)
{
ExpressionSyntax awaitedExpression = UnwrapParentheses(awaitExpression.Expression);
if (IsOneOfSymbols(context, awaitedExpression, taskSymbols))
{
return true;
}
return awaitedExpression is InvocationExpressionSyntax invocation
&& ((invocation.Expression is MemberAccessExpressionSyntax memberAccess
&& IsOneOfSymbols(context, memberAccess.Expression, taskSymbols))
|| (context.SemanticModel.GetOperation(invocation, context.CancellationToken) is IInvocationOperation invocationOperation
&& invocationOperation.Arguments.Any(argument => argument.Syntax is ArgumentSyntax argumentSyntax
&& IsOneOfSymbols(context, argumentSyntax.Expression, taskSymbols))));
}
src/Microsoft.VisualStudio.Threading.Analyzers.CSharp/CSharpCommonInterest.cs:883
- Anonymous functions are excluded from the root traversal, and this code only re-enters lambdas stored in local delegate variables. An inline consumer such as
Task.Run(async () => await task).Wait()is therefore missed; for a source-backed ValueTask this can consumetask, after which a laterIsCompletedSuccessfully/Resultpair is not safe but is suppressed. Inspect anonymous-function arguments of calls that may execute them, or invalidate the ValueTask proof for this unsupported shape.
foreach (VariableDeclaratorSyntax delegateVariable in searchRoot.DescendantNodes(DescendIntoChildren)
.OfType<VariableDeclaratorSyntax>()
.Where(variable => variable.SpanStart < accessSyntax.SpanStart
&& variable.Initializer?.Value is AnonymousFunctionExpressionSyntax))
{
var anonymousFunction = (AnonymousFunctionExpressionSyntax)delegateVariable.Initializer!.Value;
if (context.SemanticModel.GetDeclaredSymbol(delegateVariable, context.CancellationToken) is not ILocalSymbol delegateSymbol)
{
continue;
}
if (searchRoot.DescendantNodes(DescendIntoChildren)
.OfType<InvocationExpressionSyntax>()
.Any(invocation => invocation.SpanStart < accessSyntax.SpanStart
&& SymbolEqualityComparer.Default.Equals(
context.SemanticModel.GetSymbolInfo(invocation.Expression, context.CancellationToken).Symbol,
delegateSymbol)
&& NestedFunctionMayConsumeValueTask(anonymousFunction, invocation)))
{
return true;
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Summary
Task.WhenAll, completion guards, nested blocks, and intervening writesvs-threading.SyncBlockingMethods.txtwithout offering unsafe automatic rewritesWaitAnyresults, and non-GetAwaiter().GetResult()call chainsIssue outcomes
Fixes #1123
Fixes #937
Fixes #454
Fixes #344
Fixes #301
All five issues in this subset are addressed; none remain unresolved in this PR.
Validation