- 
                Notifications
    You must be signed in to change notification settings 
- Fork 715
Fix swallowed exceptions in DistributedApplicationEventing #11900
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
3138d45
              c7b974a
              8a64d35
              fa494af
              7e098e3
              aa2c0d3
              3a3e884
              File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | 
|---|---|---|
|  | @@ -2,6 +2,7 @@ | |
| // The .NET Foundation licenses this file to you under the MIT license. | ||
|  | ||
| using System.Collections.Concurrent; | ||
| using System.Threading.Channels; | ||
| using Aspire.Hosting.ApplicationModel; | ||
|  | ||
| namespace Aspire.Hosting.Eventing; | ||
|  | @@ -11,6 +12,33 @@ public class DistributedApplicationEventing : IDistributedApplicationEventing | |
| { | ||
| private readonly ConcurrentDictionary<Type, List<DistributedApplicationEventSubscription>> _eventSubscriptionListLookup = new(); | ||
| private readonly ConcurrentDictionary<DistributedApplicationEventSubscription, Type> _subscriptionEventTypeLookup = new(); | ||
| private readonly Channel<(Exception Exception, Type EventType, IResource? Resource)> _exceptionChannel = Channel.CreateUnbounded<(Exception, Type, IResource?)>(); | ||
|  | ||
| /// <summary> | ||
| /// Initializes a new instance of the <see cref="DistributedApplicationEventing"/> class. | ||
| /// </summary> | ||
| public DistributedApplicationEventing() | ||
| { | ||
| // Start a background task to process exceptions from the channel | ||
| _ = Task.Run(ProcessExceptionChannelAsync); | ||
| } | ||
|  | ||
| private async Task ProcessExceptionChannelAsync() | ||
| { | ||
| await foreach (var (exception, eventType, resource) in _exceptionChannel.Reader.ReadAllAsync().ConfigureAwait(false)) | ||
| { | ||
| try | ||
| { | ||
| var exceptionEvent = new EventPublishExceptionEvent(exception, eventType, resource); | ||
| // Use NonBlockingSequential to avoid potential deadlocks when publishing from within an event handler | ||
| await PublishAsync(exceptionEvent, EventDispatchBehavior.NonBlockingSequential).ConfigureAwait(false); | ||
| } | ||
| catch | ||
| { | ||
| // If we can't publish the exception event, there's nothing we can do | ||
| } | ||
| } | ||
| } | ||
|  | ||
| /// <inheritdoc cref="IDistributedApplicationEventing.PublishAsync{T}(T, CancellationToken)" /> | ||
| [System.Diagnostics.CodeAnalysis.SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Cancellation token")] | ||
|  | @@ -25,26 +53,38 @@ public async Task PublishAsync<T>(T @event, EventDispatchBehavior dispatchBehavi | |
| { | ||
| if (_eventSubscriptionListLookup.TryGetValue(typeof(T), out var subscriptions)) | ||
| { | ||
| // Determine the resource associated with the event if it's a resource-specific event | ||
| var resource = @event is IDistributedApplicationResourceEvent resourceEvent ? resourceEvent.Resource : null; | ||
|  | ||
| if (dispatchBehavior == EventDispatchBehavior.BlockingConcurrent || dispatchBehavior == EventDispatchBehavior.NonBlockingConcurrent) | ||
| { | ||
| var pendingSubscriptionCallbacks = new List<Task>(subscriptions.Count); | ||
| foreach (var subscription in subscriptions.ToArray()) | ||
| { | ||
| var pendingSubscriptionCallback = subscription.Callback(@event, cancellationToken); | ||
| pendingSubscriptionCallbacks.Add(pendingSubscriptionCallback); | ||
| // Wrap each callback to catch exceptions individually | ||
| var wrappedCallback = Task.Run(async () => | ||
| { | ||
| try | ||
| { | ||
| await subscription.Callback(@event, cancellationToken).ConfigureAwait(false); | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| PublishExceptionEventAsync(ex, typeof(T), resource); | ||
| throw; | ||
| } | ||
| }, cancellationToken); | ||
| pendingSubscriptionCallbacks.Add(wrappedCallback); | ||
| } | ||
|  | ||
| if (dispatchBehavior == EventDispatchBehavior.NonBlockingConcurrent) | ||
| { | ||
| // Non-blocking concurrent. | ||
| _ = Task.Run(async () => | ||
| { | ||
| await Task.WhenAll(pendingSubscriptionCallbacks).ConfigureAwait(false); | ||
| }, default); | ||
| // Non-blocking concurrent - fire and forget | ||
| _ = Task.WhenAll(pendingSubscriptionCallbacks); | ||
| There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @copilot this code is not ideal because it means that any reporting of event handlers that throw exceptions is delayed until all event handlers have completed. Instead of doing this start a Task to publish exceptions which reads from a shared Channel. Inside the exception handler that you added write the exception into the channel. The Task.WhenAll(...) can stay, but should also include the task for the exception publishing task. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done. Changed to use an unbounded Channel with a background task to process exceptions asynchronously in commit 0c4d1a2. This ensures exceptions are reported immediately without waiting for all event handlers to complete. | ||
| } | ||
| else | ||
| { | ||
| // Blocking concurrent. | ||
| // Blocking concurrent - wait for all to complete | ||
| await Task.WhenAll(pendingSubscriptionCallbacks).ConfigureAwait(false); | ||
| } | ||
| } | ||
|  | @@ -57,7 +97,15 @@ public async Task PublishAsync<T>(T @event, EventDispatchBehavior dispatchBehavi | |
| { | ||
| foreach (var subscription in subscriptions.ToArray()) | ||
| { | ||
| await subscription.Callback(@event, cancellationToken).ConfigureAwait(false); | ||
| try | ||
| { | ||
| await subscription.Callback(@event, cancellationToken).ConfigureAwait(false); | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| PublishExceptionEventAsync(ex, typeof(T), resource); | ||
| throw; | ||
| } | ||
| } | ||
| }, default); | ||
| } | ||
|  | @@ -66,13 +114,33 @@ public async Task PublishAsync<T>(T @event, EventDispatchBehavior dispatchBehavi | |
| // Blocking sequential. | ||
| foreach (var subscription in subscriptions.ToArray()) | ||
| { | ||
| await subscription.Callback(@event, cancellationToken).ConfigureAwait(false); | ||
| try | ||
| { | ||
| await subscription.Callback(@event, cancellationToken).ConfigureAwait(false); | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| PublishExceptionEventAsync(ex, typeof(T), resource); | ||
| throw; | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
|  | ||
| private void PublishExceptionEventAsync(Exception exception, Type eventType, IResource? resource) | ||
| { | ||
| // Avoid infinite loop if EventPublishExceptionEvent handler throws | ||
| if (eventType == typeof(EventPublishExceptionEvent)) | ||
| { | ||
| return; | ||
| } | ||
|  | ||
| // Write to the channel for async processing | ||
| _exceptionChannel.Writer.TryWrite((exception, eventType, resource)); | ||
| } | ||
|  | ||
| /// <inheritdoc cref="IDistributedApplicationEventing.Subscribe{T}(Func{T, CancellationToken, Task})" /> | ||
| public DistributedApplicationEventSubscription Subscribe<T>(Func<T, CancellationToken, Task> callback) where T : IDistributedApplicationEvent | ||
| { | ||
|  | ||
| Original file line number | Diff line number | Diff line change | 
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| // Licensed to the .NET Foundation under one or more agreements. | ||
| // The .NET Foundation licenses this file to you under the MIT license. | ||
|  | ||
| using Aspire.Hosting.ApplicationModel; | ||
|  | ||
| namespace Aspire.Hosting.Eventing; | ||
|  | ||
| /// <summary> | ||
| /// This event is raised when an exception occurs during event publishing. | ||
| /// </summary> | ||
| public sealed class EventPublishExceptionEvent : IDistributedApplicationEvent | ||
| { | ||
| /// <summary> | ||
| /// Initializes a new instance of the <see cref="EventPublishExceptionEvent"/> class. | ||
| /// </summary> | ||
| /// <param name="exception">The exception that was thrown.</param> | ||
| /// <param name="eventType">The type of the event that was being published.</param> | ||
| /// <param name="resource">The resource associated with the event, if it's a resource-specific event.</param> | ||
| public EventPublishExceptionEvent(Exception exception, Type eventType, IResource? resource) | ||
| { | ||
| Exception = exception; | ||
| EventType = eventType; | ||
| Resource = resource; | ||
| } | ||
|  | ||
| /// <summary> | ||
| /// The exception that was thrown. | ||
| /// </summary> | ||
| public Exception Exception { get; } | ||
|  | ||
| /// <summary> | ||
| /// The type of the event that was being published. | ||
| /// </summary> | ||
| public Type EventType { get; } | ||
|  | ||
| /// <summary> | ||
| /// The resource associated with the event, if it's a resource-specific event. | ||
| /// </summary> | ||
| public IResource? Resource { get; } | ||
| } | 
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This may not be ideal. You don't necessarily get to choose who your fire and forget partners are. If there is an event that is going to hang around waiting for interaction (for example) it would stop you being notified that your event threw an exception.