Skip to content

Commit de561c9

Browse files
authored
Merge pull request #292 from CommunityToolkit/dev/async-command-options
Add FlowExceptionsToTaskScheduler command option
2 parents 5a0c5ee + 612055b commit de561c9

File tree

14 files changed

+606
-77
lines changed

14 files changed

+606
-77
lines changed

CommunityToolkit.Mvvm.SourceGenerators/AnalyzerReleases.Shipped.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,4 @@ MVVMTK0027 | CommunityToolkit.Mvvm.SourceGenerators.ObservablePropertyGenerator
3737
MVVMTK0028 | CommunityToolkit.Mvvm.SourceGenerators.ObservablePropertyGenerator | Error | See https://aka.ms/mvvmtoolkit/error
3838
MVVMTK0029 | CommunityToolkit.Mvvm.SourceGenerators.ObservablePropertyGenerator | Warning | See https://aka.ms/mvvmtoolkit/error
3939
MVVMTK0030 | CommunityToolkit.Mvvm.SourceGenerators.ObservablePropertyGenerator | Warning | See https://aka.ms/mvvmtoolkit/error
40+
MVVMTK0031 | CommunityToolkit.Mvvm.SourceGenerators.RelayCommandGenerator | Error | See https://aka.ms/mvvmtoolkit/error

CommunityToolkit.Mvvm.SourceGenerators/Diagnostics/DiagnosticDescriptors.cs

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -191,17 +191,17 @@ internal static class DiagnosticDescriptors
191191
/// <summary>
192192
/// Gets a <see cref="DiagnosticDescriptor"/> indicating when <c>RelayCommandAttribute.AllowConcurrentExecutions</c> is being set for a non-asynchronous method.
193193
/// <para>
194-
/// Format: <c>"The method {0}.{1} cannot be annotated with the [RelayCommand] attribute specifying a concurrency control setting, as it maps to a non-asynchronous command type"</c>.
194+
/// Format: <c>"The method {0}.{1} cannot be annotated with the [RelayCommand] attribute specifying a concurrency control option, as it maps to a non-asynchronous command type"</c>.
195195
/// </para>
196196
/// </summary>
197197
public static readonly DiagnosticDescriptor InvalidConcurrentExecutionsParameterError = new DiagnosticDescriptor(
198198
id: "MVVMTK0012",
199-
title: "Invalid concurrency control setting usage",
200-
messageFormat: "The method {0}.{1} cannot be annotated with the [RelayCommand] attribute specifying a concurrency control setting, as it maps to a non-asynchronous command type",
199+
title: "Invalid concurrency control option usage",
200+
messageFormat: "The method {0}.{1} cannot be annotated with the [RelayCommand] attribute specifying a concurrency control option, as it maps to a non-asynchronous command type",
201201
category: typeof(RelayCommandGenerator).FullName,
202202
defaultSeverity: DiagnosticSeverity.Error,
203203
isEnabledByDefault: true,
204-
description: "Cannot apply the [RelayCommand] attribute specifying a concurrency control setting to methods mapping to non-asynchronous command types.",
204+
description: "Cannot apply the [RelayCommand] attribute specifying a concurrency control option to methods mapping to non-asynchronous command types.",
205205
helpLinkUri: "https://aka.ms/mvvmtoolkit");
206206

207207
/// <summary>
@@ -491,4 +491,20 @@ internal static class DiagnosticDescriptors
491491
isEnabledByDefault: true,
492492
description: "Annotating a field with [NotifyDataErrorInfo] is not necessary if the containing type has or inherits [NotifyDataErrorInfo] at the class-level.",
493493
helpLinkUri: "https://aka.ms/mvvmtoolkit");
494+
495+
/// <summary>
496+
/// Gets a <see cref="DiagnosticDescriptor"/> indicating when <c>RelayCommandAttribute.FlowExceptionsToTaskScheduler</c> is being set for a non-asynchronous method.
497+
/// <para>
498+
/// Format: <c>"The method {0}.{1} cannot be annotated with the [RelayCommand] attribute specifying an exception flow option, as it maps to a non-asynchronous command type"</c>.
499+
/// </para>
500+
/// </summary>
501+
public static readonly DiagnosticDescriptor InvalidFlowExceptionsToTaskSchedulerParameterError = new DiagnosticDescriptor(
502+
id: "MVVMTK0031",
503+
title: "Invalid task scheduler exception flow option usage",
504+
messageFormat: "The method {0}.{1} cannot be annotated with the [RelayCommand] attribute specifying a task scheduler exception flow option, as it maps to a non-asynchronous command type",
505+
category: typeof(RelayCommandGenerator).FullName,
506+
defaultSeverity: DiagnosticSeverity.Error,
507+
isEnabledByDefault: true,
508+
description: "Cannot apply the [RelayCommand] attribute specifying a task scheduler exception flow option to methods mapping to non-asynchronous command types.",
509+
helpLinkUri: "https://aka.ms/mvvmtoolkit");
494510
}

CommunityToolkit.Mvvm.SourceGenerators/Input/Models/CommandInfo.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ namespace CommunityToolkit.Mvvm.SourceGenerators.Input.Models;
2525
/// <param name="CanExecuteMemberName">The member name for the can execute check, if available.</param>
2626
/// <param name="CanExecuteExpressionType">The can execute expression type, if available.</param>
2727
/// <param name="AllowConcurrentExecutions">Whether or not concurrent executions have been enabled.</param>
28+
/// <param name="FlowExceptionsToTaskScheduler">Whether or not exceptions should flow to the task scheduler.</param>
2829
/// <param name="IncludeCancelCommand">Whether or not to also generate a cancel command.</param>
2930
internal sealed record CommandInfo(
3031
string MethodName,
@@ -38,6 +39,7 @@ internal sealed record CommandInfo(
3839
string? CanExecuteMemberName,
3940
CanExecuteExpressionType? CanExecuteExpressionType,
4041
bool AllowConcurrentExecutions,
42+
bool FlowExceptionsToTaskScheduler,
4143
bool IncludeCancelCommand)
4244
{
4345
/// <summary>
@@ -59,6 +61,7 @@ protected override void AddToHashCode(ref HashCode hashCode, CommandInfo obj)
5961
hashCode.Add(obj.CanExecuteMemberName);
6062
hashCode.Add(obj.CanExecuteExpressionType);
6163
hashCode.Add(obj.AllowConcurrentExecutions);
64+
hashCode.Add(obj.FlowExceptionsToTaskScheduler);
6265
hashCode.Add(obj.IncludeCancelCommand);
6366
}
6467

@@ -77,6 +80,7 @@ protected override bool AreEqual(CommandInfo x, CommandInfo y)
7780
x.CanExecuteMemberName == y.CanExecuteMemberName &&
7881
x.CanExecuteExpressionType == y.CanExecuteExpressionType &&
7982
x.AllowConcurrentExecutions == y.AllowConcurrentExecutions &&
83+
x.FlowExceptionsToTaskScheduler == y.FlowExceptionsToTaskScheduler &&
8084
x.IncludeCancelCommand == y.IncludeCancelCommand;
8185
}
8286
}

CommunityToolkit.Mvvm.SourceGenerators/Input/RelayCommandGenerator.Execute.cs

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,17 @@ internal static class Execute
7171
goto Failure;
7272
}
7373

74+
// Check the switch to control exception flow
75+
if (!TryGetFlowExceptionsToTaskSchedulerSwitch(
76+
methodSymbol,
77+
attributeData,
78+
commandClassType,
79+
builder,
80+
out bool flowExceptionsToTaskScheduler))
81+
{
82+
goto Failure;
83+
}
84+
7485
// Get the CanExecute expression type, if any
7586
if (!TryGetCanExecuteExpressionType(
7687
methodSymbol,
@@ -109,6 +120,7 @@ internal static class Execute
109120
canExecuteMemberName,
110121
canExecuteExpressionType,
111122
allowConcurrentExecutions,
123+
flowExceptionsToTaskScheduler,
112124
generateCancelCommand);
113125

114126
Failure:
@@ -205,9 +217,37 @@ public static ImmutableArray<MemberDeclarationSyntax> GetSyntax(CommandInfo comm
205217
}
206218

207219
// Enable concurrent executions, if requested
208-
if (commandInfo.AllowConcurrentExecutions)
220+
if (commandInfo.AllowConcurrentExecutions && !commandInfo.FlowExceptionsToTaskScheduler)
221+
{
222+
commandCreationArguments.Add(
223+
Argument(MemberAccessExpression(
224+
SyntaxKind.SimpleMemberAccessExpression,
225+
IdentifierName("global::CommunityToolkit.Mvvm.Input.AsyncRelayCommandOptions"),
226+
IdentifierName("AllowConcurrentExecutions"))));
227+
}
228+
else if (commandInfo.FlowExceptionsToTaskScheduler && !commandInfo.AllowConcurrentExecutions)
209229
{
210-
commandCreationArguments.Add(Argument(LiteralExpression(SyntaxKind.TrueLiteralExpression)));
230+
// Enable exception flow, if requested
231+
commandCreationArguments.Add(
232+
Argument(MemberAccessExpression(
233+
SyntaxKind.SimpleMemberAccessExpression,
234+
IdentifierName("global::CommunityToolkit.Mvvm.Input.AsyncRelayCommandOptions"),
235+
IdentifierName("FlowExceptionsToTaskScheduler"))));
236+
}
237+
else if (commandInfo.AllowConcurrentExecutions && commandInfo.FlowExceptionsToTaskScheduler)
238+
{
239+
// Enable both concurrency control and exception flow
240+
commandCreationArguments.Add(
241+
Argument(BinaryExpression(
242+
SyntaxKind.BitwiseOrExpression,
243+
MemberAccessExpression(
244+
SyntaxKind.SimpleMemberAccessExpression,
245+
IdentifierName("global::CommunityToolkit.Mvvm.Input.AsyncRelayCommandOptions"),
246+
IdentifierName("AllowConcurrentExecutions")),
247+
MemberAccessExpression(
248+
SyntaxKind.SimpleMemberAccessExpression,
249+
IdentifierName("global::CommunityToolkit.Mvvm.Input.AsyncRelayCommandOptions"),
250+
IdentifierName("FlowExceptionsToTaskScheduler")))));
211251
}
212252

213253
// Construct the generated property as follows (the explicit delegate cast is needed to avoid overload resolution conflicts):
@@ -555,6 +595,43 @@ private static bool TryGetAllowConcurrentExecutionsSwitch(
555595
}
556596
}
557597

598+
/// <summary>
599+
/// Checks whether or not the user has requested to configure the task scheduler exception flow option.
600+
/// </summary>
601+
/// <param name="methodSymbol">The input <see cref="IMethodSymbol"/> instance to process.</param>
602+
/// <param name="attributeData">The <see cref="AttributeData"/> instance the method was annotated with.</param>
603+
/// <param name="commandClassType">The command class type name.</param>
604+
/// <param name="diagnostics">The current collection of gathered diagnostics.</param>
605+
/// <param name="flowExceptionsToTaskScheduler">Whether or not task scheduler exception flow have been enabled.</param>
606+
/// <returns>Whether or not a value for <paramref name="flowExceptionsToTaskScheduler"/> could be retrieved successfully.</returns>
607+
private static bool TryGetFlowExceptionsToTaskSchedulerSwitch(
608+
IMethodSymbol methodSymbol,
609+
AttributeData attributeData,
610+
string commandClassType,
611+
ImmutableArray<Diagnostic>.Builder diagnostics,
612+
out bool flowExceptionsToTaskScheduler)
613+
{
614+
// Try to get the custom switch for task scheduler exception flow (the default is false)
615+
if (!attributeData.TryGetNamedArgument("FlowExceptionsToTaskScheduler", out flowExceptionsToTaskScheduler))
616+
{
617+
flowExceptionsToTaskScheduler = false;
618+
619+
return true;
620+
}
621+
622+
// Just like with the concurrency control option, check that the target command type is asynchronous
623+
if (commandClassType is "global::CommunityToolkit.Mvvm.Input.AsyncRelayCommand")
624+
{
625+
return true;
626+
}
627+
else
628+
{
629+
diagnostics.Add(InvalidFlowExceptionsToTaskSchedulerParameterError, methodSymbol, methodSymbol.ContainingType, methodSymbol);
630+
631+
return false;
632+
}
633+
}
634+
558635
/// <summary>
559636
/// Checks whether or not the user has requested to also generate a cancel command.
560637
/// </summary>

CommunityToolkit.Mvvm/Input/AsyncRelayCommand.cs

Lines changed: 45 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,9 @@ public sealed class AsyncRelayCommand : IAsyncRelayCommand, ICancellationAwareCo
5959
private readonly Func<bool>? canExecute;
6060

6161
/// <summary>
62-
/// Indicates whether or not concurrent executions of the command are allowed.
62+
/// The options being set for the current command.
6363
/// </summary>
64-
private readonly bool allowConcurrentExecutions;
64+
private readonly AsyncRelayCommandOptions options;
6565

6666
/// <summary>
6767
/// The <see cref="CancellationTokenSource"/> instance to use to cancel <see cref="cancelableExecute"/>.
@@ -91,14 +91,14 @@ public AsyncRelayCommand(Func<Task> execute)
9191
/// Initializes a new instance of the <see cref="AsyncRelayCommand"/> class.
9292
/// </summary>
9393
/// <param name="execute">The execution logic.</param>
94-
/// <param name="allowConcurrentExecutions">Whether or not to allow concurrent executions of the command.</param>
94+
/// <param name="options">The options to use to configure the async command.</param>
9595
/// <exception cref="System.ArgumentNullException">Thrown if <paramref name="execute"/> is <see langword="null"/>.</exception>
96-
public AsyncRelayCommand(Func<Task> execute, bool allowConcurrentExecutions)
96+
public AsyncRelayCommand(Func<Task> execute, AsyncRelayCommandOptions options)
9797
{
9898
ArgumentNullException.ThrowIfNull(execute);
9999

100100
this.execute = execute;
101-
this.allowConcurrentExecutions = allowConcurrentExecutions;
101+
this.options = options;
102102
}
103103

104104
/// <summary>
@@ -117,14 +117,14 @@ public AsyncRelayCommand(Func<CancellationToken, Task> cancelableExecute)
117117
/// Initializes a new instance of the <see cref="AsyncRelayCommand"/> class.
118118
/// </summary>
119119
/// <param name="cancelableExecute">The cancelable execution logic.</param>
120-
/// <param name="allowConcurrentExecutions">Whether or not to allow concurrent executions of the command.</param>
120+
/// <param name="options">The options to use to configure the async command.</param>
121121
/// <exception cref="System.ArgumentNullException">Thrown if <paramref name="cancelableExecute"/> is <see langword="null"/>.</exception>
122-
public AsyncRelayCommand(Func<CancellationToken, Task> cancelableExecute, bool allowConcurrentExecutions)
122+
public AsyncRelayCommand(Func<CancellationToken, Task> cancelableExecute, AsyncRelayCommandOptions options)
123123
{
124124
ArgumentNullException.ThrowIfNull(cancelableExecute);
125125

126126
this.cancelableExecute = cancelableExecute;
127-
this.allowConcurrentExecutions = allowConcurrentExecutions;
127+
this.options = options;
128128
}
129129

130130
/// <summary>
@@ -147,16 +147,16 @@ public AsyncRelayCommand(Func<Task> execute, Func<bool> canExecute)
147147
/// </summary>
148148
/// <param name="execute">The execution logic.</param>
149149
/// <param name="canExecute">The execution status logic.</param>
150-
/// <param name="allowConcurrentExecutions">Whether or not to allow concurrent executions of the command.</param>
150+
/// <param name="options">The options to use to configure the async command.</param>
151151
/// <exception cref="System.ArgumentNullException">Thrown if <paramref name="execute"/> or <paramref name="canExecute"/> are <see langword="null"/>.</exception>
152-
public AsyncRelayCommand(Func<Task> execute, Func<bool> canExecute, bool allowConcurrentExecutions)
152+
public AsyncRelayCommand(Func<Task> execute, Func<bool> canExecute, AsyncRelayCommandOptions options)
153153
{
154154
ArgumentNullException.ThrowIfNull(execute);
155155
ArgumentNullException.ThrowIfNull(canExecute);
156156

157157
this.execute = execute;
158158
this.canExecute = canExecute;
159-
this.allowConcurrentExecutions = allowConcurrentExecutions;
159+
this.options = options;
160160
}
161161

162162
/// <summary>
@@ -179,16 +179,16 @@ public AsyncRelayCommand(Func<CancellationToken, Task> cancelableExecute, Func<b
179179
/// </summary>
180180
/// <param name="cancelableExecute">The cancelable execution logic.</param>
181181
/// <param name="canExecute">The execution status logic.</param>
182-
/// <param name="allowConcurrentExecutions">Whether or not to allow concurrent executions of the command.</param>
182+
/// <param name="options">The options to use to configure the async command.</param>
183183
/// <exception cref="System.ArgumentNullException">Thrown if <paramref name="cancelableExecute"/> or <paramref name="canExecute"/> are <see langword="null"/>.</exception>
184-
public AsyncRelayCommand(Func<CancellationToken, Task> cancelableExecute, Func<bool> canExecute, bool allowConcurrentExecutions)
184+
public AsyncRelayCommand(Func<CancellationToken, Task> cancelableExecute, Func<bool> canExecute, AsyncRelayCommandOptions options)
185185
{
186186
ArgumentNullException.ThrowIfNull(cancelableExecute);
187187
ArgumentNullException.ThrowIfNull(canExecute);
188188

189189
this.cancelableExecute = cancelableExecute;
190190
this.canExecute = canExecute;
191-
this.allowConcurrentExecutions = allowConcurrentExecutions;
191+
this.options = options;
192192
}
193193

194194
private Task? executionTask;
@@ -238,7 +238,7 @@ static async void MonitorTask(AsyncRelayCommand @this, Task task)
238238
@this.PropertyChanged?.Invoke(@this, CanBeCanceledChangedEventArgs);
239239
}
240240

241-
if (!@this.allowConcurrentExecutions)
241+
if ((@this.options & AsyncRelayCommandOptions.AllowConcurrentExecutions) == 0)
242242
{
243243
@this.CanExecuteChanged?.Invoke(@this, EventArgs.Empty);
244244
}
@@ -273,13 +273,20 @@ public bool CanExecute(object? parameter)
273273
{
274274
bool canExecute = this.canExecute?.Invoke() != false;
275275

276-
return canExecute && (this.allowConcurrentExecutions || ExecutionTask is not { IsCompleted: false });
276+
return canExecute && ((this.options & AsyncRelayCommandOptions.AllowConcurrentExecutions) != 0 || ExecutionTask is not { IsCompleted: false });
277277
}
278278

279279
/// <inheritdoc/>
280280
public void Execute(object? parameter)
281281
{
282-
_ = ExecuteAsync(parameter);
282+
Task executionTask = ExecuteAsync(parameter);
283+
284+
// If exceptions shouldn't flow to the task scheduler, await the resulting task. This is
285+
// delegated to a separate method to keep this one more compact in case the option is set.
286+
if ((this.options & AsyncRelayCommandOptions.FlowExceptionsToTaskScheduler) == 0)
287+
{
288+
AwaitAndThrowIfFailed(executionTask);
289+
}
283290
}
284291

285292
/// <inheritdoc/>
@@ -304,7 +311,7 @@ public Task ExecuteAsync(object? parameter)
304311
}
305312

306313
// If concurrent executions are disabled, notify the can execute change as well
307-
if (!this.allowConcurrentExecutions)
314+
if ((this.options & AsyncRelayCommandOptions.AllowConcurrentExecutions) == 0)
308315
{
309316
CanExecuteChanged?.Invoke(this, EventArgs.Empty);
310317
}
@@ -323,4 +330,24 @@ public void Cancel()
323330
PropertyChanged?.Invoke(this, IsCancellationRequestedChangedEventArgs);
324331
}
325332
}
333+
334+
/// <summary>
335+
/// Awaits an input <see cref="Task"/> and throws an exception on the calling context, if the task fails.
336+
/// </summary>
337+
/// <param name="executionTask">The input <see cref="Task"/> instance to await.</param>
338+
internal static async void AwaitAndThrowIfFailed(Task executionTask)
339+
{
340+
// Note: this method is purposefully an async void method awaiting the input task. This is done so that
341+
// if an async relay command is invoked synchronously (ie. when Execute is called, eg. from a binding),
342+
// exceptions in the wrapped delegate will not be ignored or just become visible through the ExecutionTask
343+
// property, but will be rethrown in the original synchronization context by default. This makes the behavior
344+
// more consistent with how normal commands work (where exceptions are also just normally propagated to the
345+
// caller context), and avoids getting an app into an inconsistent state in case a method faults without
346+
// other components being notified. It is also possible to not await this task and to instead ignore exceptions
347+
// and then inspect them manually from the ExecutionTask property, by constructing an async command instance
348+
// using the AsyncRelayCommandOptions.FlowExceptionsToTaskScheduler option. That will cause this call to
349+
// be skipped, and exceptions will just either normally be available through that property, or will otherwise
350+
// flow to the static TaskScheduler.UnobservedTaskException event if otherwise unobserved (eg. for logging).
351+
await executionTask;
352+
}
326353
}

0 commit comments

Comments
 (0)