Skip to content

Opt-in ExecutionFailed event with Handled semantics on the relay commands #1205

Description

@ArieGato

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/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

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<T> : 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<RelayCommandExceptionEventArgs>? ExecutionFailed;
}

public sealed class RelayCommand<T> : IRelayCommand<T>
{
    public event EventHandler<RelayCommandExceptionEventArgs<T>>? ExecutionFailed;
}

public sealed class AsyncRelayCommand : IAsyncRelayCommand
{
    public event EventHandler<RelayCommandExceptionEventArgs>? ExecutionFailed;
}

public sealed class AsyncRelayCommand<T> : IAsyncRelayCommand<T>
{
    public event EventHandler<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 Handled
public sealed class RelayCommandAttribute : Attribute
{
    public string? 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
Subscriber, Handled = true Exception suppressed; command completes normally (an awaited ExecuteAsync task completes successfully)
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:

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:

AsyncRelayCommand<Document> command = ...;

command.ExecutionFailed += (s, e) =>
{
    // e is RelayCommandExceptionEventArgs<Document>: the failing parameter, strongly typed
    this.logger.LogError(e.Exception, "Save failed for {Document}", e.Parameter);

    e.Handled = true;
};

With the [RelayCommand] generator:

[RelayCommand(OnException = nameof(OnSaveFailed))]
private async Task SaveAsync() => await this.repository.SaveAsync(this.document);

// Signature 1: routing to the handler is handling — no rethrow.
private void OnSaveFailed(Exception exception) => this.ErrorMessage = exception.Message;
[RelayCommand(OnException = nameof(OnSaveFailed))]
private async Task SaveAsync() => await this.repository.SaveAsync(this.document);

// Signature 2: full control — decide per exception.
private void OnSaveFailed(RelayCommandExceptionEventArgs e)
{
    this.logger.LogError(e.Exception, "Save failed");

    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 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.
  • Static event (as proposed in [Feature] AsyncRelayCommand.Execute should catch and raise an Exception if possible #22): global state, wrong granularity; an instance event keeps handling local to the command.

Additional context

  • 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 sync RelayCommand 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions