CommunityToolkit / CommunityToolkit/dotnet
Opt-in ExecutionFailed event with Handled semantics on the relay commands
- Dominant language
- C#
- Stars
- 3.8k
- Forks
- 400
- PR merge metrics
- No merged PRs in 30d
Description
## Overview
There is currently no way to intercept an exception thrown by a synchronous `RelayCommand`: `Execute` invokes the wrapped delegate with no try/catch, all four command classes are `sealed` (deliberately, for devirtualization and AOT), `IRelayCommand` exposes no exception hook, and the `[RelayCommand]` generator owns command construction. The exception propagates synchronously into whatever invoked the command — typically the UI framework's binding plumbing — where centralized handling is awkward and view-model-specific context is already lost.
For the async commands the situation is only slightly better: a fault can be observed indirectly via `PropertyChanged` + `ExecutionTask` inspection, or routed to `TaskScheduler.UnobservedTaskException` with `AsyncRelayCommandOptions.FlowExceptionsToTaskScheduler` — both global or indirect mechanisms, neither of which offers a local, per-command handler, and neither of which exists at all for the sync commands.
Applications that want "every command failure goes through my error handler" (logging, user-facing error surfaces, retry policies) currently have to wrap every delegate in try/catch by hand, or build wrapper/decorator infrastructure around the toolkit's commands — per command, easy to forget, and invisible to the `[RelayCommand]` generator.
This proposes one uniform, opt-in seam across all four command classes, following the established .NET interception idiom (`Application.ThreadException`, `AppDomain.UnhandledException`, WPF's `DispatcherUnhandledException`): raise an event with the exception; rethrow unless a subscriber marks it handled. With no subscriber, behavior is exactly today's.
## API breakdown
```csharp
namespace CommunityToolkit.Mvvm.Input;
// New event args types
public class RelayCommandExceptionEventArgs : EventArgs
{
public RelayCommandExceptionEventArgs(Exception exception);
public Exception Exception { get; }
// Default false = rethrow after the event (today's propagation, preserved).
// Any subscriber setting true suppresses propagation.
public bool Handled { get; set; }
}
// Raised by the generic commands: exposes the strongly typed parameter that was passed
// to the failing execution — the per-invocation context a centralized handler otherwise loses.
public sealed class RelayCommandExceptionEventArgs : RelayCommandExceptionEventArgs
{
public RelayCommandExceptionEventArgs(T? parameter, Exception exception);
public T? Parameter { get; }
}
// New event on all four sealed command classes
// (not on IRelayCommand: netstandard2.0 has no default interface members,
// so an interface member would break every external implementor)
public sealed class RelayCommand : IRelayCommand
{
public event EventHandler? ExecutionFailed;
}
public sealed class RelayCommand : IRelayCommand
{
public event EventHandler>? ExecutionFailed;
}
public sealed class AsyncRelayCommand : IAsyncRelayCommand
{
public event EventHandler? ExecutionFailed;
}
public sealed class AsyncRelayCommand : IAsyncRelayCommand
{
public event EventHandler>? ExecutionFailed;
}
// New knob on the [RelayCommand] attribute, valid on all command shapes.
// Accepts a method name in the containing type with one of three signatures:
// void M(Exception) -> generator subscribes and auto-sets Handled = true
// void M(RelayCommandExceptionEventArgs) -> full control, handler decides Handled
// void M(RelayCommandExceptionEventArgs) -> same, on a command with a parameter of type T,
// so Parameter is reachable without a cast
public sealed class RelayCommandAttribute : Attribute
{
public string? OnExecutionFailed { get; init; }
}
```
The attribute property is named after the event it wires up, matching how the other options on the attribute are named after what they configure (`CanExecute`, `IncludeCancelCommand`, `FlowExceptionsToTaskScheduler`). It is deliberately not `OnException`, which would promise more than the feature delivers — a canceled execution and an invalid command parameter never reach the handler.
## Semantics
- **Sync commands:** `Execute` checks the event before invoking the wrapped delegate and, only when it has subscribers, dispatches to a private method that owns the `try`/`catch`. All exceptions route, including `OperationCanceledException` (sync commands have no cancellation concept). No subscriber → no exception handling region is entered or even present on that path, propagation byte-identical to today.
- **Async commands:** routing is task-status-based. A **Faulted** task raises the event, even when the fault is an `OperationCanceledException` (e.g. `Task.FromException(oce)`); a **Canceled** task never does, and existing cancellation propagation is untouched.
- **The two execution paths differ in what they can do about the exception, because only one of them owns it.** On the `ICommand.Execute` path nobody can catch the fault, so it is routed to the event and `Handled` suppresses the rethrow. A caller that awaits `ExecuteAsync` holds the task and observes the fault itself, so the event is raised purely as a notification and the exception is **always** delivered to the awaiter; `Handled` has no effect there. `ExecuteAsync` therefore always returns the execution task itself, preserving reference identity with `ExecutionTask` — a completed task never reports success for a failed operation.
- **Handler lookup follows standard event semantics:** the handler list is read when the exception is raised, so a handler detached while the delegate was running is not notified. The one deviation is that when no handler is attached at invocation the delegate runs with no exception handling in place, so a handler attached *during* that execution is not notified for it.
- **Generator diagnostics:** `MVVMTK0057` (no matching member), `MVVMTK0058` (matching name, incompatible signature — including generic methods, `out`/`ref` parameters, and members inaccessible from the generated code), `MVVMTK0059` (several valid matches, with the same overridden-hierarchy carve-out `CanExecute` uses), and `MVVMTK0060` (**warning**: an `async void` handler passes signature validation since `ReturnsVoid` is true for it, but the command cannot await it, so the fault is treated as observed once the handler reaches its first `await` and anything thrown after that reaches the synchronization context unhandled).
Backward-compatibility matrix:
| State | Behavior |
|---|---|
| No subscriber | Identical to today (sync: `Execute` contains no exception handling region at all; async: `ExecuteAsync` returns the execution task itself — reference identity preserved) |
| Subscriber, `Handled` left `false` | Event raised, then rethrow — the caller still sees the original exception |
| Subscriber, `Handled = true` | Exception suppressed on the `ICommand.Execute` path; an awaited `ExecuteAsync` still delivers it |
| Async task Canceled | Event never raised; current propagation untouched |
No interface changes, no constructor changes, no unsealing of the command classes, no virtual members; the attribute property is init-only and opt-in.
## Usage example
Plain command:
```csharp
AsyncRelayCommand saveCommand = new(SaveAsync);
saveCommand.ExecutionFailed += (s, e) =>
{
this.logger.LogError(e.Exception, "Save failed");
this.ErrorMessage = e.Exception.Message;
e.Handled = true; // suppress propagation; omit to observe-and-rethrow
};
```
Generic command — the args expose the failing parameter, strongly typed:
```csharp
AsyncRelayCommand command = ...;
command.ExecutionFailed += (s, e) =>
{
// e is RelayCommandExceptionEventArgs
this.logger.LogError(e.Exception, "Save failed for {Document}", e.Parameter);
e.Handled = true;
};
```
With the `[RelayCommand]` generator:
```csharp
[RelayCommand(OnExecutionFailed = nameof(OnSaveFailed))]
private async Task SaveAsync() => await this.repository.SaveAsync(this.document);
// Signature 1: routing to the handler is handling — no rethrow on the ICommand.Execute path.
private void OnSaveFailed(Exception exception) => this.ErrorMessage = exception.Message;
```
```csharp
[RelayCommand(OnExecutionFailed = nameof(OnSaveFailed))]
private async Task SaveAsync(Document document) => await this.repository.SaveAsync(document);
// Signature 3: full control, and Parameter without a cast.
private void OnSaveFailed(RelayCommandExceptionEventArgs e)
{
this.logger.LogError(e.Exception, "Save failed for {Document}", e.Parameter);
e.Handled = e.Exception is not CriticalAppException; // rethrow the critical ones
}
```
## Breaking change?
No.
## Alternatives
What can be used today:
- **Manual try/catch in every command method** (or a `RunGuarded`-style wrapper delegate): works, but is per-command boilerplate, easy to forget, and for generated commands forces the exception handling into the command method body.
- **`AsyncRelayCommandOptions.FlowExceptionsToTaskScheduler` + `TaskScheduler.UnobservedTaskException`:** async-only, global rather than per-command, and timing-dependent (raised on finalization).
- **Observing `PropertyChanged` for `ExecutionTask` and inspecting task status:** async-only, indirect, requires deduplication bookkeeping, and cannot suppress propagation.
- **Wrapper/decorator command classes:** possible, but the toolkit's commands are sealed, so wrappers cannot preserve the concrete types the generator emits, and `[RelayCommand]` users cannot inject them at all.
Design alternatives considered and rejected:
- **Constructor callback (`Action`) overloads:** non-breaking but doubles `AsyncRelayCommand`'s already numerous constructor overloads, has a lambda-ambiguity edge on `RelayCommand` when `T` is `Exception`, and cannot be attached after construction.
- **Unsealing / virtual `Execute`:** the classes are sealed deliberately (devirtualization, AOT); a perf-regressing change.
- **Static event** (as proposed in #22): global state, wrong granularity; an instance event keeps handling local to the command.
- **Letting `Handled` suppress the exception for a caller awaiting `ExecuteAsync`:** would require returning a task other than `ExecutionTask`, so a completed task could report success for an operation that failed. Rejected — `Handled` is suppression of last resort, and only meaningful where the exception has nowhere else to go.
## Performance
The unsubscribed path is kept entirely free of exception handling so there is zero behavioral difference when the feature is not used. An earlier revision used an exception filter in `Execute` for that purpose; measurement showed the cost, since RyuJIT will not inline a method containing an exception handler at all. On net8.0 x64 the Tier1 code for `RelayCommand.Execute` grew from 48 to 297 bytes, the tail call to the wrapped delegate was lost, and the method stopped being inlined into its caller. Hoisting the `try`/`catch` into a separate method reached only when the event has subscribers brings that to 62 bytes, restores the tail call and the inlining, and leaves throughput indistinguishable from the current release under BenchmarkDotNet.
Each command instance grows by one reference field (8 bytes on 64-bit). On the async subscribed path, one `async void` state machine observes the execution task; the unsubscribed path allocates nothing extra.
## Known limitations
Recorded deliberately rather than chased, as each is either inherent to the design or narrow enough that the cure looks worse than the disease. Happy to be argued out of any of them.
- **A subscriber supersedes `FlowExceptionsToTaskScheduler`.** Raising the event requires observing the task, and letting a fault reach `TaskScheduler.UnobservedTaskException` requires *not* observing it. Both cannot hold, so subscribing wins and an unhandled fault is rethrown on the captured context.
- **`ExecutionFailed` is not on `IRelayCommand`/`IAsyncRelayCommand`** (no default interface members on netstandard2.0). Generated command properties are interface-typed, so subscribing manually to a generated command requires a cast to the concrete type. Open to guidance if a different shape is preferred.
- **A handler that throws replaces the original exception** and prevents later handlers running — ordinary multicast event behavior, pinned by tests rather than special-cased.
- **A `Task`-returning delegate that throws synchronously** (rather than returning a faulted task) escapes before any task exists, so the event is not raised, whereas the equivalent synchronous command does raise it.
- **Argument binding failures are not routed:** the `ArgumentException` from an incompatible command parameter is a usage error raised before the wrapped delegate runs.
- **No correlation handle for concurrent parameterless commands.** With `AllowConcurrentExecutions`, `ExecutionTask` may already point at a later execution when a handler runs. Commands with a parameter correlate via `Parameter`; parameterless ones cannot. Adding the faulting task to the event args would close this, if wanted.
- **A valid handler hidden by a `new` slot** reports MVVMTK0059 rather than binding to the derived one, mirroring existing `CanExecute` behavior.
## Additional context
- Prior art: #22 proposed a static exception event on `AsyncRelayCommand` (closed; async fault flow was addressed with `FlowExceptionsToTaskScheduler`). In [discussion #175](https://github.com/CommunityToolkit/dotnet/discussions/175), on a fault event: "If this is something that is considered particularly useful I guess we could add a dedicated event for this in a future release." No proposal on record covers the **sync** `RelayCommand` gap.
- A complete implementation exists and is ready to submit as a PR: the runtime event on all four classes, the typed args, generator support for `OnExecutionFailed`, the four diagnostics, and test coverage across unit, generator-snapshot, generator-diagnostic and end-to-end levels. The entire existing suite passes untouched.
## Help us help you
Yes, I'd like to be assigned to work on this item.
Contributor guide
Research direction
Start by reading the existing RelayCommand and AsyncRelayCommand implementations, then trace RelayCommandAttribute handling in the [RelayCommand] generator. Check how synchronous and asynchronous execution paths expose faults and how generator diagnostics are defined. Done means the opt-in event, Handled semantics, generic parameter context, attribute wiring, and specified diagnostics work consistently without interface or constructor changes.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- csharp
- Domain
- backend-api-design, developer-experience
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 30/100