Skip to content

Commit 56f10f3

Browse files
committed
Add JoinableTaskContext.CreateNoOpContext() method
The `JoinableTaskContext` constructor unfortunately has a poor design where folks passing in `null` for the `SynchronizationContext` may mistakenly believe that they're getting a no-op instance when they aren't, if `SynchronizationContext.Current != null`. In this change, I add a method that *definitely* does what they expect. I also add an analyzer and a code fix provider to help identify code that was likely written with the incorrect expectation to help them switch to the new method which matches their expectation, or suppress the warning with a more clear syntax.
1 parent d405bea commit 56f10f3

File tree

16 files changed

+608
-7
lines changed

16 files changed

+608
-7
lines changed

doc/analyzers/VSTHRD115.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# VSTHRD115 Avoid creating a JoinableTaskContext with an explicit `null` `SynchronizationContext`
2+
3+
Constructing a `JoinableTaskContext` with an explicit `null` `SynchronizationContext` is not recommended as a means to construct an instance for use in unit tests or processes without a main thread.
4+
This is because the constructor will automatically use `SynchronizationContext.Current` in lieu of a non-`null` argument.
5+
If `SynchronizationContext.Current` happens to be non-`null`, the constructor may unexpectedly configure the new instance as if a main thread were present.
6+
7+
## Examples of patterns that are flagged by this analyzer
8+
9+
```csharp
10+
void SetupJTC() {
11+
this.jtc = new JoinableTaskContext(null, null);
12+
}
13+
```
14+
15+
This code *appears* to configure the `JoinableTaskContext` to not be associated with any `SynchronizationContext`.
16+
But in fact it will be associated with the current `SynchronizationContext` if one is present.
17+
18+
## Solution
19+
20+
If you intended to inherit `SynchronizationContext.Current` to initialize with a main thread,
21+
provide that value explicitly as the second argument to suppress the warning:
22+
23+
```cs
24+
void SetupJTC() {
25+
this.jtc = new JoinableTaskContext(null, SynchronizationContext.Current);
26+
}
27+
```
28+
29+
If you intended to create a `JoinableTaskContext` for use in a unit test or in a process without a main thread,
30+
call `JoinableTaskContext.CreateNoOpContext()` instead:
31+
32+
```cs
33+
void SetupJTC() {
34+
this.jtc = JoinableTaskContext.CreateNoOpContext();
35+
}
36+
```
37+
38+
Code fixes are offered to update code to either of the above patterns.

doc/analyzers/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ ID | Title | Severity | Supports | Default diagnostic severity
2828
[VSTHRD112](VSTHRD112.md) | Implement `System.IAsyncDisposable` | Advisory | | Info
2929
[VSTHRD113](VSTHRD113.md) | Check for `System.IAsyncDisposable` | Advisory | | Info
3030
[VSTHRD114](VSTHRD114.md) | Avoid returning null from a `Task`-returning method. | Advisory | | Warning
31+
[VSTHRD115](VSTHRD115.md) | Avoid creating a JoinableTaskContext with an explicit `null` `SynchronizationContext` | Advisory | | Warning
3132
[VSTHRD200](VSTHRD200.md) | Use `Async` naming convention | Guideline | [VSTHRD103](VSTHRD103.md) | Warning
3233

3334
## Severity descriptions

src/Microsoft.VisualStudio.Threading.Analyzers.CodeFixes/FixUtils.cs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
using Microsoft.CodeAnalysis.FindSymbols;
1313
using Microsoft.CodeAnalysis.Rename;
1414
using Microsoft.CodeAnalysis.Simplification;
15+
using CSSyntax = Microsoft.CodeAnalysis.CSharp.Syntax;
16+
using VB = Microsoft.CodeAnalysis.VisualBasic;
17+
using VBSyntax = Microsoft.CodeAnalysis.VisualBasic.Syntax;
1518

1619
namespace Microsoft.VisualStudio.Threading.Analyzers;
1720

@@ -330,6 +333,22 @@ internal static NameSyntax QualifyName(IReadOnlyList<string> qualifiers, SimpleN
330333

331334
internal static async Task<SyntaxNode> GetSyntaxRootOrThrowAsync(this Document document, CancellationToken cancellationToken) => await document.GetSyntaxRootAsync(cancellationToken) ?? throw new InvalidOperationException("No syntax root could be obtained from the document.");
332335

336+
internal static (SyntaxNode? Creation, SyntaxNode[]? Arguments) FindObjectCreationSyntax(SyntaxNode startFrom)
337+
{
338+
if (startFrom is CSharpSyntaxNode && startFrom.FirstAncestorOrSelf<CSSyntax.ObjectCreationExpressionSyntax>() is { } csCreation)
339+
{
340+
return (csCreation, csCreation.ArgumentList?.Arguments.ToArray());
341+
}
342+
else if (startFrom is VB.VisualBasicSyntaxNode && startFrom.FirstAncestorOrSelf<VBSyntax.ObjectCreationExpressionSyntax>() is { } vbCreation)
343+
{
344+
return (vbCreation, vbCreation.ArgumentList?.Arguments.ToArray());
345+
}
346+
else
347+
{
348+
return (null, null);
349+
}
350+
}
351+
333352
private static CSharpSyntaxNode UpdateStatementsForAsyncMethod(CSharpSyntaxNode body, SemanticModel? semanticModel, bool hasResultValue, CancellationToken cancellationToken)
334353
{
335354
var blockBody = body as BlockSyntax;
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
3+
4+
using System.Collections.Immutable;
5+
using System.Threading;
6+
using System.Threading.Tasks;
7+
using Microsoft.CodeAnalysis;
8+
using Microsoft.CodeAnalysis.CodeActions;
9+
using Microsoft.CodeAnalysis.CodeFixes;
10+
using Microsoft.CodeAnalysis.Editing;
11+
12+
namespace Microsoft.VisualStudio.Threading.Analyzers;
13+
14+
[ExportCodeFixProvider(LanguageNames.CSharp)]
15+
public class VSTHRD115AvoidJoinableTaskContextCtorWithNullArgsCodeFix : CodeFixProvider
16+
{
17+
public const string SuppressWarningEquivalenceKey = "SuppressWarning";
18+
19+
public const string UseFactoryMethodEquivalenceKey = "UseFactoryMethod";
20+
21+
private static readonly ImmutableArray<string> ReusableFixableDiagnosticIds = ImmutableArray.Create(
22+
VSTHRD115AvoidJoinableTaskContextCtorWithNullArgsAnalyzer.Id);
23+
24+
/// <inheritdoc />
25+
public override ImmutableArray<string> FixableDiagnosticIds => ReusableFixableDiagnosticIds;
26+
27+
/// <inheritdoc />
28+
public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer;
29+
30+
public override async Task RegisterCodeFixesAsync(CodeFixContext context)
31+
{
32+
foreach (Diagnostic diagnostic in context.Diagnostics)
33+
{
34+
SyntaxNode? root = await context.Document.GetSyntaxRootAsync(context.CancellationToken);
35+
if (root is null)
36+
{
37+
continue;
38+
}
39+
40+
if (!diagnostic.Properties.TryGetValue(VSTHRD115AvoidJoinableTaskContextCtorWithNullArgsAnalyzer.NodeTypePropertyName, out string? nodeType) || nodeType is null)
41+
{
42+
continue;
43+
}
44+
45+
context.RegisterCodeFix(CodeAction.Create(Strings.VSTHRD115_CodeFix_Suppress_Title, ct => this.SuppressDiagnostic(context, root, nodeType, diagnostic, ct), SuppressWarningEquivalenceKey), diagnostic);
46+
47+
if (diagnostic.Properties.TryGetValue(VSTHRD115AvoidJoinableTaskContextCtorWithNullArgsAnalyzer.UsesDefaultThreadPropertyName, out string? usesDefaultThreadString) && usesDefaultThreadString is "true")
48+
{
49+
context.RegisterCodeFix(CodeAction.Create(Strings.VSTHRD115_CodeFix_UseFactory_Title, ct => this.SwitchToFactory(context, root, nodeType, diagnostic, ct), UseFactoryMethodEquivalenceKey), diagnostic);
50+
}
51+
}
52+
}
53+
54+
private async Task<Document> SuppressDiagnostic(CodeFixContext context, SyntaxNode root, string nodeType, Diagnostic diagnostic, CancellationToken cancellationToken)
55+
{
56+
SyntaxGenerator generator = SyntaxGenerator.GetGenerator(context.Document);
57+
SyntaxNode targetNode = root.FindNode(diagnostic.Location.SourceSpan, getInnermostNodeForTie: true);
58+
59+
Compilation? compilation = await context.Document.Project.GetCompilationAsync(cancellationToken);
60+
if (compilation is null)
61+
{
62+
return context.Document;
63+
}
64+
65+
ITypeSymbol? syncContext = compilation.GetTypeByMetadataName("System.Threading.SynchronizationContext");
66+
if (syncContext is null)
67+
{
68+
return context.Document;
69+
}
70+
71+
ITypeSymbol? jtc = compilation.GetTypeByMetadataName(Types.JoinableTaskContext.FullName);
72+
if (jtc is null)
73+
{
74+
return context.Document;
75+
}
76+
77+
SyntaxNode syncContextCurrent = generator.MemberAccessExpression(generator.TypeExpression(syncContext, addImport: true), nameof(SynchronizationContext.Current));
78+
switch (nodeType)
79+
{
80+
case VSTHRD115AvoidJoinableTaskContextCtorWithNullArgsAnalyzer.NodeTypeArgument:
81+
root = root.ReplaceNode(targetNode, syncContextCurrent);
82+
break;
83+
case VSTHRD115AvoidJoinableTaskContextCtorWithNullArgsAnalyzer.NodeTypeCreation:
84+
(SyntaxNode? creationNode, SyntaxNode[]? args) = FixUtils.FindObjectCreationSyntax(targetNode);
85+
if (creationNode is null || args is null)
86+
{
87+
return context.Document;
88+
}
89+
90+
SyntaxNode threadArg = args.Length >= 1 ? args[0] : generator.Argument(generator.NullLiteralExpression());
91+
SyntaxNode syncContextArg = generator.Argument(syncContextCurrent);
92+
93+
root = root.ReplaceNode(creationNode, generator.ObjectCreationExpression(jtc, threadArg, syncContextArg));
94+
break;
95+
}
96+
97+
Document modifiedDocument = context.Document.WithSyntaxRoot(root);
98+
return modifiedDocument;
99+
}
100+
101+
private async Task<Document> SwitchToFactory(CodeFixContext context, SyntaxNode root, string nodeType, Diagnostic diagnostic, CancellationToken cancellationToken)
102+
{
103+
SyntaxGenerator generator = SyntaxGenerator.GetGenerator(context.Document);
104+
SyntaxNode targetNode = root.FindNode(diagnostic.Location.SourceSpan, getInnermostNodeForTie: true);
105+
106+
Compilation? compilation = await context.Document.Project.GetCompilationAsync(cancellationToken);
107+
if (compilation is null)
108+
{
109+
return context.Document;
110+
}
111+
112+
(SyntaxNode? creationExpression, _) = FixUtils.FindObjectCreationSyntax(targetNode);
113+
if (creationExpression is null)
114+
{
115+
return context.Document;
116+
}
117+
118+
ITypeSymbol? jtc = compilation.GetTypeByMetadataName(Types.JoinableTaskContext.FullName);
119+
if (jtc is null)
120+
{
121+
return context.Document;
122+
}
123+
124+
SyntaxNode factoryExpression = generator.InvocationExpression(generator.MemberAccessExpression(generator.TypeExpression(jtc, addImport: true), Types.JoinableTaskContext.CreateNoOpContext));
125+
126+
root = root.ReplaceNode(creationExpression, factoryExpression);
127+
128+
Document modifiedDocument = context.Document.WithSyntaxRoot(root);
129+
return modifiedDocument;
130+
}
131+
}

src/Microsoft.VisualStudio.Threading.Analyzers/Strings.Designer.cs

Lines changed: 36 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Microsoft.VisualStudio.Threading.Analyzers/Strings.resx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,4 +341,16 @@ Start the work within this context, or use JoinableTaskFactory.RunAsync to start
341341
<value>Use 'Task.FromResult' instead</value>
342342
<comment>"Task.FromResult" should not be translated.</comment>
343343
</data>
344+
<data name="VSTHRD115_Title" xml:space="preserve">
345+
<value>Avoid creating JoinableTaskContext with null SynchronizationContext</value>
346+
</data>
347+
<data name="VSTHRD115_MessageFormat" xml:space="preserve">
348+
<value>Avoid creating JoinableTaskContext with 'null' as the value for the SynchronizationContext because behavior varies by the value of SynchronizationContext.Current</value>
349+
</data>
350+
<data name="VSTHRD115_CodeFix_Suppress_Title" xml:space="preserve">
351+
<value>Specify 'SynchronizationContext.Current' explicitly</value>
352+
</data>
353+
<data name="VSTHRD115_CodeFix_UseFactory_Title" xml:space="preserve">
354+
<value>Use 'JoinableTaskContext.CreateNoOpContext' instead.</value>
355+
</data>
344356
</root>

src/Microsoft.VisualStudio.Threading.Analyzers/Types.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ namespace Microsoft.VisualStudio.Threading.Analyzers;
1111
/// </summary>
1212
internal static class Types
1313
{
14+
private const string VSThreadingNamespace = "Microsoft.VisualStudio.Threading";
15+
1416
internal static class BclAsyncDisposable
1517
{
1618
internal const string FullName = "System.IAsyncDisposable";
@@ -131,6 +133,10 @@ internal static class JoinableTaskCollection
131133
internal static class JoinableTaskContext
132134
{
133135
internal const string TypeName = "JoinableTaskContext";
136+
137+
internal const string FullName = $"{VSThreadingNamespace}.{TypeName}";
138+
139+
internal const string CreateNoOpContext = "CreateNoOpContext";
134140
}
135141

136142
internal static class JoinableTask
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
3+
4+
using System;
5+
using System.Collections.Immutable;
6+
using System.Linq;
7+
using System.Threading;
8+
using Microsoft.CodeAnalysis;
9+
using Microsoft.CodeAnalysis.Diagnostics;
10+
using Microsoft.CodeAnalysis.Operations;
11+
12+
namespace Microsoft.VisualStudio.Threading.Analyzers;
13+
14+
/// <summary>
15+
/// Flags the use of <c>new JoinableTaskContext(null, null)</c> and advises using <c>JoinableTaskContext.CreateNoOpContext</c> instead.
16+
/// </summary>
17+
[DiagnosticAnalyzer(LanguageNames.CSharp, LanguageNames.VisualBasic)]
18+
public class VSTHRD115AvoidJoinableTaskContextCtorWithNullArgsAnalyzer : DiagnosticAnalyzer
19+
{
20+
public const string Id = "VSTHRD115";
21+
22+
internal const string UsesDefaultThreadPropertyName = "UsesDefaultThread";
23+
24+
internal const string NodeTypePropertyName = "NodeType";
25+
26+
internal const string NodeTypeArgument = "Argument";
27+
28+
internal const string NodeTypeCreation = "Creation";
29+
30+
internal static readonly DiagnosticDescriptor Descriptor = new DiagnosticDescriptor(
31+
id: Id,
32+
title: new LocalizableResourceString(nameof(Strings.VSTHRD115_Title), Strings.ResourceManager, typeof(Strings)),
33+
messageFormat: new LocalizableResourceString(nameof(Strings.VSTHRD115_MessageFormat), Strings.ResourceManager, typeof(Strings)),
34+
description: null,
35+
helpLinkUri: Utils.GetHelpLink(Id),
36+
category: "Usage",
37+
defaultSeverity: DiagnosticSeverity.Warning,
38+
isEnabledByDefault: true);
39+
40+
/// <inheritdoc />
41+
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => ImmutableArray.Create(Descriptor);
42+
43+
public override void Initialize(AnalysisContext context)
44+
{
45+
context.EnableConcurrentExecution();
46+
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.Analyze);
47+
48+
context.RegisterCompilationStartAction(startCompilation =>
49+
{
50+
INamedTypeSymbol? joinableTaskContextType = startCompilation.Compilation.GetTypeByMetadataName(Types.JoinableTaskContext.FullName);
51+
if (joinableTaskContextType is not null)
52+
{
53+
IMethodSymbol? problematicCtor = joinableTaskContextType.InstanceConstructors.SingleOrDefault(ctor => ctor.Parameters.Length == 2 && ctor.Parameters[0].Type.Name == nameof(Thread) && ctor.Parameters[1].Type.Name == nameof(SynchronizationContext));
54+
55+
if (problematicCtor is not null && joinableTaskContextType.GetMembers(Types.JoinableTaskContext.CreateNoOpContext).Length > 0)
56+
{
57+
startCompilation.RegisterOperationAction(Utils.DebuggableWrapper(c => this.AnalyzeObjectCreation(c, joinableTaskContextType, problematicCtor)), OperationKind.ObjectCreation);
58+
}
59+
}
60+
});
61+
}
62+
63+
private void AnalyzeObjectCreation(OperationAnalysisContext context, INamedTypeSymbol joinableTaskContextType, IMethodSymbol problematicCtor)
64+
{
65+
IObjectCreationOperation creation = (IObjectCreationOperation)context.Operation;
66+
if (SymbolEqualityComparer.Default.Equals(creation.Constructor, problematicCtor))
67+
{
68+
// Only flag if "null" is passed in as the constructor's second argument (explicitly or implicitly).
69+
if (creation.Arguments.Length == 2)
70+
{
71+
IOperation arg2 = creation.Arguments[1].Value;
72+
if (arg2 is IConversionOperation { Operand: ILiteralOperation { ConstantValue: { HasValue: true, Value: null } } literal })
73+
{
74+
context.ReportDiagnostic(Diagnostic.Create(Descriptor, literal.Syntax.GetLocation(), CreateProperties(NodeTypeArgument)));
75+
}
76+
else if (arg2 is IDefaultValueOperation { ConstantValue: { HasValue: true, Value: null } })
77+
{
78+
context.ReportDiagnostic(Diagnostic.Create(Descriptor, creation.Syntax.GetLocation(), CreateProperties(NodeTypeCreation)));
79+
}
80+
81+
ImmutableDictionary<string, string?> CreateProperties(string nodeType)
82+
{
83+
// The caller is using the default thread if they omit the argument, pass in "null", or pass in "Thread.CurrentThread".
84+
// At the moment, we are not testing for the Thread.CurrentThread case.
85+
bool usesDefaultThread = creation.Arguments[0].Value is IDefaultValueOperation { ConstantValue: { HasValue: true, Value: null } }
86+
or IConversionOperation { Operand: ILiteralOperation { ConstantValue: { HasValue: true, Value: null } } };
87+
88+
return ImmutableDictionary.Create<string, string?>()
89+
.Add(UsesDefaultThreadPropertyName, usesDefaultThread ? "true" : "false")
90+
.Add(NodeTypePropertyName, nodeType);
91+
}
92+
}
93+
}
94+
}
95+
}

0 commit comments

Comments
 (0)