You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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/event 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 — the sync path uses an exception filter, so the unsubscribed path never even enters a catch block and the original stack is fully preserved.
API breakdown
namespaceCommunityToolkit.Mvvm.Input;// New event args typespublicclassRelayCommandExceptionEventArgs:EventArgs{publicRelayCommandExceptionEventArgs(Exceptionexception);publicExceptionException{get;}// Default false = rethrow after the event (today's propagation, preserved).// Any subscriber setting true suppresses propagation.publicboolHandled{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.publicsealedclassRelayCommandExceptionEventArgs<T>:RelayCommandExceptionEventArgs{publicRelayCommandExceptionEventArgs(T?parameter,Exceptionexception);publicT?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)publicsealedclassRelayCommand:IRelayCommand{publiceventEventHandler<RelayCommandExceptionEventArgs>?ExecutionFailed;}publicsealedclassRelayCommand<T>:IRelayCommand<T>{publiceventEventHandler<RelayCommandExceptionEventArgs<T>>?ExecutionFailed;}publicsealedclassAsyncRelayCommand:IAsyncRelayCommand{publiceventEventHandler<RelayCommandExceptionEventArgs>?ExecutionFailed;}publicsealedclassAsyncRelayCommand<T>:IAsyncRelayCommand<T>{publiceventEventHandler<RelayCommandExceptionEventArgs<T>>?ExecutionFailed;}// New knob on the [RelayCommand] attribute, valid on all command shapes.// Accepts a method name in the containing type with one of two signatures:// void M(Exception) -> generator subscribes and auto-sets Handled = true// void M(RelayCommandExceptionEventArgs) -> full control, handler decides HandledpublicsealedclassRelayCommandAttribute:Attribute{publicstring?OnException{get;init;}}
A handler typed against the base args (void M(RelayCommandExceptionEventArgs)) still binds to the generic events through method group contravariance, so one handler can serve all four command shapes — and for the same reason the [RelayCommand] generator's emitted subscription is arity-independent (the generic args require no generator changes).
Semantics:
Sync commands: interception via an exception filter in Execute. All exceptions route to the event, including OperationCanceledException (sync commands have no cancellation concept). No subscriber → filter never matches, catch never entered, propagation byte-identical to today.
Async commands: task-status-based. The event is raised when the execution task completes Faulted (even when the fault is an OperationCanceledException, e.g. Task.FromException(oce)); a Canceled task never raises it, and existing cancellation propagation is untouched. The event participates in both execution paths: when at least one handler is attached at the time ExecuteAsync is invoked (including when invoked through ICommand.Execute), the returned task routes a fault through the event first and completes successfully if a handler marks it as handled — switching a command between sync and async therefore never relocates exception handling out of the view model. With no handlers attached at that time, ExecuteAsync returns the execution task itself (same instance as ExecutionTask, reference identity preserved) and faults propagate to the awaiter unchanged. On the Execute path, an unhandled fault is rethrown on the captured context, exactly as AwaitAndThrowIfFailed does today.
Interaction with FlowExceptionsToTaskScheduler: a subscribed ExecutionFailed intercepts fault propagation — the fault is routed to the event (and thereby observed, so it no longer reaches TaskScheduler.UnobservedTaskException); if not Handled, it is rethrown on the captured context. With no subscriber, today's flow-to-scheduler behavior is unchanged. Rationale: subscribing the event is the more explicit and more local opt-in, and once an exception object is handed to a handler it is unavoidably observed.
Generator diagnostics: three new error diagnostics for OnException — no matching member, invalid signature, ambiguous valid matches (with the same override-hierarchy carve-out as CanExecute's MVVMTK0010).
Backward-compatibility matrix:
State
Behavior
No subscriber
Identical to today (sync: exception filter never enters the catch; async: ExecuteAsync returns the execution task itself — reference identity preserved)
Subscriber, Handled left false
Event raised, then rethrow — the caller (or the awaiter of ExecuteAsync) still sees the original exception
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:
AsyncRelayCommandsaveCommand=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:
AsyncRelayCommand<Document>command= ...;command.ExecutionFailed+=(s,e)=>{// e is RelayCommandExceptionEventArgs<Document>: the failing parameter, strongly typedthis.logger.LogError(e.Exception,"Save failed for {Document}",e.Parameter);e.Handled=true;};
With the [RelayCommand] generator:
[RelayCommand(OnException=nameof(OnSaveFailed))]privateasyncTaskSaveAsync()=>awaitthis.repository.SaveAsync(this.document);// Signature 1: routing to the handler is handling — no rethrow.privatevoidOnSaveFailed(Exceptionexception)=>this.ErrorMessage=exception.Message;
[RelayCommand(OnException=nameof(OnSaveFailed))]privateasyncTaskSaveAsync()=>awaitthis.repository.SaveAsync(this.document);// Signature 2: full control — decide per exception.privatevoidOnSaveFailed(RelayCommandExceptionEventArgse){this.logger.LogError(e.Exception,"Save failed");e.Handled=e.Exceptionis 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 for this proposal:
Constructor callback (Action<Exception>) overloads: non-breaking but doubles AsyncRelayCommand's already numerous constructor overloads, has a lambda-ambiguity edge on RelayCommand<T> when T is Exception, and cannot be attached after construction.
Unsealing / virtual Execute: the classes are sealed deliberately (devirtualization, AOT); a perf-regressing change.
Prior art: [Feature] AsyncRelayCommand.Execute should catch and raise an Exception if possible #22 proposed a static exception event on AsyncRelayCommand (closed; async fault flow was addressed with FlowExceptionsToTaskScheduler). In discussion #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 syncRelayCommand gap.
A complete implementation exists and is ready to submit as a PR: runtime event on all four classes, generator support for OnException, the three diagnostics, and full test coverage (unit, generator snapshot, and end-to-end tests on generated commands; entire existing suite passes untouched — no existing test modified).
The unsubscribed sync path uses an exception filter specifically so there is zero behavioral difference when the feature is not used (no catch frame, original stack preserved). One honest trade-off to discuss: the try/catch in Execute makes the method non-inlineable even when unsubscribed; a guarded fast path is possible if that matters for the perf bar. On the async side, subscribers are sampled when ExecuteAsync is invoked: when present, the returned task is a thin routing wrapper (allocated only in that case); a subscriber attached after the call is not routed for that invocation, and ExecutionTask always remains the inner execution task.
Help us help you
Yes, I'd like to be assigned to work on this item.
Overview
There is currently no way to intercept an exception thrown by a synchronous
RelayCommand:Executeinvokes the wrapped delegate with no try/catch, all four command classes aresealed(deliberately, for devirtualization and AOT),IRelayCommandexposes 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/event 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+ExecutionTaskinspection, or routed toTaskScheduler.UnobservedTaskExceptionwithAsyncRelayCommandOptions.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'sDispatcherUnhandledException): raise an event with the exception; rethrow unless a subscriber marks it handled. With no subscriber, behavior is exactly today's — the sync path uses an exception filter, so the unsubscribed path never even enters a catch block and the original stack is fully preserved.API breakdown
A handler typed against the base args (
void M(RelayCommandExceptionEventArgs)) still binds to the generic events through method group contravariance, so one handler can serve all four command shapes — and for the same reason the[RelayCommand]generator's emitted subscription is arity-independent (the generic args require no generator changes).Semantics:
Execute. All exceptions route to the event, includingOperationCanceledException(sync commands have no cancellation concept). No subscriber → filter never matches, catch never entered, propagation byte-identical to today.OperationCanceledException, e.g.Task.FromException(oce)); a Canceled task never raises it, and existing cancellation propagation is untouched. The event participates in both execution paths: when at least one handler is attached at the timeExecuteAsyncis invoked (including when invoked throughICommand.Execute), the returned task routes a fault through the event first and completes successfully if a handler marks it as handled — switching a command between sync and async therefore never relocates exception handling out of the view model. With no handlers attached at that time,ExecuteAsyncreturns the execution task itself (same instance asExecutionTask, reference identity preserved) and faults propagate to the awaiter unchanged. On theExecutepath, an unhandled fault is rethrown on the captured context, exactly asAwaitAndThrowIfFaileddoes today.FlowExceptionsToTaskScheduler: a subscribedExecutionFailedintercepts fault propagation — the fault is routed to the event (and thereby observed, so it no longer reachesTaskScheduler.UnobservedTaskException); if notHandled, it is rethrown on the captured context. With no subscriber, today's flow-to-scheduler behavior is unchanged. Rationale: subscribing the event is the more explicit and more local opt-in, and once an exception object is handed to a handler it is unavoidably observed.OnException— no matching member, invalid signature, ambiguous valid matches (with the same override-hierarchy carve-out asCanExecute's MVVMTK0010).Backward-compatibility matrix:
ExecuteAsyncreturns the execution task itself — reference identity preserved)HandledleftfalseExecuteAsync) still sees the original exceptionHandled = trueExecuteAsynctask completes successfully)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:
Generic command — the args expose the failing parameter, strongly typed:
With the
[RelayCommand]generator:Breaking change?
No.
Alternatives
What can be used today:
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).PropertyChangedforExecutionTaskand inspecting task status: async-only, indirect, requires deduplication bookkeeping, and cannot suppress propagation.[RelayCommand]users cannot inject them at all.Design alternatives considered and rejected for this proposal:
Action<Exception>) overloads: non-breaking but doublesAsyncRelayCommand's already numerous constructor overloads, has a lambda-ambiguity edge onRelayCommand<T>whenTisException, and cannot be attached after construction.Execute: the classes are sealed deliberately (devirtualization, AOT); a perf-regressing change.Additional context
AsyncRelayCommand(closed; async fault flow was addressed withFlowExceptionsToTaskScheduler). In discussion #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 syncRelayCommandgap.OnException, the three diagnostics, and full test coverage (unit, generator snapshot, and end-to-end tests on generated commands; entire existing suite passes untouched — no existing test modified).Executemakes the method non-inlineable even when unsubscribed; a guarded fast path is possible if that matters for the perf bar. On the async side, subscribers are sampled whenExecuteAsyncis invoked: when present, the returned task is a thin routing wrapper (allocated only in that case); a subscriber attached after the call is not routed for that invocation, andExecutionTaskalways remains the inner execution task.Help us help you
Yes, I'd like to be assigned to work on this item.