0

I have this event handler:

private void OnShutdownRequested(object? sender, ShutdownRequestedEventArgs e)
{
   var canShutdown = lifetime.CanShutdown();
   e.Cancel = !canShutdown;
}

Now, due to design decisions the CanShutdown method has changed from bool to Task<bool>

Task<bool> CanShutDown()
{
   //...
}

So, I need to modify the event handler like this:

private async void OnShutdownRequested(object? sender, ShutdownRequestedEventArgs e)
{
   var canShutdown = await lifetime.CanShutdown();
   e.Cancel = !canShutdown;
}

I've read many times that async void methods have many problems, like unhandled exceptions being thrown directly inside the SynchronizationContext. But one of the valid usages for them are event handlers. This is an event handler. Isn't it?

BUT, I wonder if that code is free of undesired consequences after the "migration".

My concern is that the handler modifies the value of e.Cancel.

A colleague has told me that this will happen:

After await, the caller to that method isn't awaiting. It assumes synchronous execution, so it immediately reads e.Cancel, and that hasn't been set yet. That is a problem inside event handler: You realize as soon as the await keyword is hit, the code that called ShutdownRequested.Invoke() immediately returns. The value it will read might not be up-to-date.

I'm afraid my colleague has his point. So, it seems this approach is broken. But I still don't see how to fix that.

How to deal with the EventArgs being shared by sync and async code?

SuperJMN
  • 13,110
  • 16
  • 86
  • 185
  • Does the API provide a manual way to initiate a `Shutdown`? – Theodor Zoulias Apr 12 '22 at 11:20
  • @TheodorZoulias Yes, via a `public static void Shutdown()` – SuperJMN Apr 12 '22 at 12:45
  • If you set `e.Cancel = true`, and after awaiting the `lifetime.CanShutdown()` you call `Shutdown()` from inside the `OnShutdownRequested` event handler, what is going to happen? Is the `ShutdownRequested` event going to be raised again? If yes, and if you call again the `lifetime.CanShutdown` method, is the `Task` going to complete asynchronously again, or it's possible that the second invocation will return a completed `Task`? – Theodor Zoulias Apr 12 '22 at 13:10
  • @TheodorZoulias I'd say it's not expected to call to Shutdown inside OnShutdownRequested! – SuperJMN Apr 12 '22 at 13:59
  • 1
    Where should the `Shutdown` be called then? Clearly the `OnShutdownRequested` handler must always set `e.Cancel = true` if it can't make synchronously a decision whether to cancel or not. Because if it allows the `e.Cancel` to remain false, the app will shutdown, making any future decisions about it irrelevant. – Theodor Zoulias Apr 12 '22 at 14:07
  • 1
    @TheodorZoulias That's the point. You're right. The problem is that the cancelling mechanism isn't thought to be asynchronous. Event handlers using EventArgs to cancel or mark the request as handled cannot be turned async unless the underlying framework (the one that launches the event) is designed with this in mind. Take WPF as an example with a Click event. It's the very same situation. – SuperJMN Apr 12 '22 at 14:24
  • 1
    Two related questions: [How do I await events in C#?](https://stackoverflow.com/questions/27761852/how-do-i-await-events-in-c) / [How to await an event handler invocation?](https://stackoverflow.com/questions/58077892/how-to-await-an-event-handler-invocation) – Theodor Zoulias Apr 12 '22 at 14:32

2 Answers2

2

I've read many times that async void methods have many problems, like unhandled exceptions being thrown directly inside the SynchronizationContext. But one of the valid usages for them are event handlers.

Yes. In fact, throwing exceptions on the SynchronizationContext is actually deliberate behavior specifically to emulate the behavior of event handlers.

One of the primary problems with async void method is that it's not easy to determine when the async void method has completed. Which is exactly the problem your colleague is pointing out.

This is an event handler. Isn't it?

Sort of.

Take a step back and consider the design patterns being used. The Observer pattern is a way to notify observers of state changes. The Observer pattern is a clear fit for "events" in OOP: any number of observers may subscribe to state change notifications.

This kind of "shutdown" notification is not just a notification, though. It also has a return value. Generally, this is the Strategy pattern. The Strategy pattern is not a good fit for events in OOP. However, many times the Strategy pattern is (mis-)implemented with an event; this is a common design mistake in OOP languages.

So, is it an event handler? Technically, yes. Should it be an event handler? Probably not.

In the synchronous world, implementing the Strategy pattern with events is often "close enough". Sometimes there's some confusion about how the return value should be handled in case there are multiple event subscribers, but in general this kind of design mistake goes unnoticed. Until async comes along, and suddenly the design mistake of using events for the Strategy pattern becomes more apparent.

But I still don't see how to fix that.

I describe a few possibilities on my blog.

If you control OnShutdownRequested and ShutdownRequestedEventArgs, then you can use deferrals. It's a bit complex to set up, but it allows both synchronous and asynchronous handlers (as long as the handlers use a deferral), and the code that raises the event can (asynchronously) wait for all handlers to complete before retrieving the results.

If shutdown is the only event you have to worry about, then one common trick is to always set Cancel to true, do the asynchronous work, and then if the shutdown is permitted, explicitly do a shutdown at the end of that asynchronous work.

Stephen Cleary
  • 437,863
  • 77
  • 675
  • 810
1

Your colleague is correct, the posted example code will most likely not work as intended.

I would suggest making ShutdownRequestedEventArgs contain something like a List<Task<bool>>, so the caller can await all the task to determine if any of them wants to cancel before shutting down. This might also include a timeout, so that some hanged task does not block the process forever. This moves the problem from the handler of the event to the caller, that hopefully is in a better position to deal with the problem.

Another possibility could be to wait for the task synchronously:

private void OnShutdownRequested(object? sender, ShutdownRequestedEventArgs e)
{
   var canShutdown = lifetime.CanShutdown().Result;
   e.Cancel = !canShutdown;
}

But this may be dangerous in a UI program. If the ShutdownRequested is requested on the UI thread, and CanShutdown needs to execute any code on the UI thread, then the program will deadlock. And this is fairly likely if CanShutdown is not written specifically to avoid this. See configureAwait for more details .

You might also just revert to the synchronous solution. An event like ShutdownRequested does not sound like it is a good fit for an asynchronous solution. I would have expected that such an event would require a immediate response. But I do not know the background of your application or why the method was changed in the first place, so there might very well be good reasons for it.

JonasH
  • 28,608
  • 2
  • 10
  • 23
  • 1
    At least you should mention ConfigureAwait in case a Synchronisation context is involved. – Clemens Apr 12 '22 at 12:15
  • @Clemens if `Task CanShutdown()` is well designed, with ConfigureAwait(false) used properly, calling Result shouldn't cause deadlocks, shoud it? – SuperJMN Apr 12 '22 at 12:48
  • 1
    @SuperJMN that is my understanding. But I it might result in fragile code, i.e. only require a new developer to forget `ConfigureAwait` to cause problems. So I would recommend being *very* through with comments and unit tests if going for such a solution. – JonasH Apr 12 '22 at 12:59
  • 1
    @SuperJMN: Yes, there should be no deadlocks when ConfigureAwait is set to false. If set to false, there is no SynchnonisationContext and thus the continuation can be invoked on any context and not on the stored one (which is waiting) – Clemens Apr 12 '22 at 13:07
  • 2
    @JonasH: There is static code analysis that you can apply to enforce the proper use of ConfigureAwait. – Clemens Apr 12 '22 at 13:07