Starting example...
Running the task...
Starting task that throws async...
[TryAsync Error Callback] Our exception handler caught: System.AggregateException: One or more errors occurred. (This is our exception)
- - -> System.InvalidOperationException: This is our exception
at EventHandlers.<>c.<taskthatthrowsasync>b__2_0() in C:\my\lib\vs\iot\vxi\src\apps\cc.isr.VXI11.AsyncEventHandlersTester\Program.cs:line 125
at System.Threading.ExecutionContext.RunFromThreadPoolDispatchLoop(Thread threadPoolThread, ExecutionContext executionContext, ContextCallback callback, Object state)
- - - End of stack trace from previous location ---
at System.Threading.ExecutionContext.RunFromThreadPoolDispatchLoop(Thread threadPoolThread, ExecutionContext executionContext, ContextCallback callback, Object state)
at System.Threading.Tasks.Task.ExecuteWithThreadLocal(Task& currentTaskSlot, Thread threadPoolThread)
- - - End of inner exception stack trace - - -
Example complete.
Note, though, that the IDE treats this exception as unhandled. Consequently, on the first run, the IDE stopped on the exception and displayed a dialog giving me the option to not stop on this exception for this particular dll.
Hey thanks for taking the time to read and ask your question!
Conceptually I would say the goal here is accomplishing roughly the same thing, but the focus is on dealing with a situation that is historically forcing us to use "async void" (which is the real deal breaker). The "async void" situation brings on a world of pain once we have to start worrying about exceptions at that "async void" boundary. That's when you can have stuff silently breaking and uncaught in the app.
Side note, I think the IDE treats this as unhandled in your example because in the current context it's in (i.e. within that particular Task execution), it is unhandled. Kind of confusing because of course, you're right, eventually it is handled
But to summarize, what you are proposing is essentially what I am chasing except for situations where we have async void. A common spot that happens is event handlers (which is why I made this follow up article specifically addressing event handlers) because you're forced into async void syntax because of the default signature. In your example, you have async Tasks (not void) so you have the happy path, even if there are exceptions
Let me know if that helps answer. Otherwise, maybe you can clarify further and I can try again!
Thank you very much for the detailed reply. Task programming is quite a humbling, challenging, and fun experience.
I put together a bit more elaborate program file, which you can find here.
I added a handler for unhandled exceptions, which, as expected, does not handle the exception that is handled in the .ContinueWith element but also helps to see how the 'base case' crashes the application.
Do you think there is a way our friends at Microsoft could extend the context of the exception to include .ContinueWith so that the exception would be taken as being 'handled'?
This is super cool, thanks for putting this together
Let me have a read through and see what I can come up with from what you have there! I just wanted to reply first to say I am looking, but it might take me a bit to play with the code first haha
As for Microsoft influence... While I work there as a Principal Eng Manager, I work in a *completely* unrelated area to some of the C# and language development I'd like to imagine if there was an elegant solution proposed they'd consider it. However, there are people working on C# that are probably orders of magnitude more intelligent than I am that have thought about this already haha
Let's see where we get to though... I'll get back to you!
Upon further investigation, I am wondering if you might be misunderstanding what the IDE is saying vs what your code is doing. Or maybe I was misunderstanding the current concern
I really wanted to upload a screenshot of an alteration I made and the output in the Visual Studio window. I think you might be seeing visual studio say an exception was thrown "but was not handled in user code". I think that's probably because there is legitimately no try/catch in the vicinity of where the exception is thrown. It's likely crossing some async boundary before it's caught.
However, your continuation example *does* in fact catch the exceptions and not break anything. To prove it, I even made your error handler callback throw ANOTHER exception, and your try/catch around awaiting the task works:
C#
Exception? ex = null;
try
{
await AsyncTaskThatThrowsAsync((ex) =>
{
Console.WriteLine($"[{exampleDescription}] Our exception handler caught aggregate exception: {ex.Message}\n");
foreach (Exception exep in ex.InnerExceptions)
Console.WriteLine($"[{exampleDescription}] inner exception: {exep.Message}\nStack Trace: \n{exep.StackTrace}");
// !!! I added this line!!thrownew InvalidOperationException("Rethrown from error handler!!", ex);
});
}
catch (Exception excep)
{
// !!! ONLY with my new line will this now catch the exception.// The original exception is truly handled by the continuation already!
ex = excep;
}
finally
{
if (ex isnull)
Console.WriteLine($"[{exampleDescription}] Event handler completed; exception not caught.");
else
Console.WriteLine($"[{exampleDescription}] Our exception handler caught: {ex.Message}\nStack Trace: \n{ex.StackTrace}");
}
Furthermore, to be extra sure, I added this (which you may want in your code to try):
C#
System.AppDomain.CurrentDomain.UnhandledException += OnUnhandledException;
// !!! This line right here :)
TaskScheduler.UnobservedTaskException += TaskScheduler_UnobservedTaskException;
And even with that extra unobserved task exception AND me throwing that new exception, still no issue.
Just to be extra extra safe, I waited 10 seconds instead (btw you can actually change that delay to be
C#
await Task.Delay(100)
instead of Wait())... still, neither of those handlers fired.
This leads me to believe everything about your continuation approach is fine and does catch exceptions (so maybe just confusion around how the IDE prints that out in debugging).
I did want to mention though that your example *is* fundamentally different because exceptions behave with we have async tasks... But they do not behave when we have async void
OKAY now the fun stuff (I'll probably make a whole article on this). I added two more scenarios that try to align what you were proposing with event handlers to get that async void unhappiness:
C#
case ErrorHandlerExample.ContinueWithEventHandler:
{
exampleDescription = "Solution 5: Continue With inside of Event Handler";
handler += async (s, e) =>
{
await AsyncTaskThatThrowsAsync((ex) =>
{
Console.WriteLine($"[{exampleDescription}] Our exception handler caught aggregate exception: {ex.Message}\n");
foreach (Exception exep in ex.InnerExceptions)
Console.WriteLine($"[{exampleDescription}] inner exception: {exep.Message}\nStack Trace: \n{exep.StackTrace}");
//throw new InvalidOperationException("Rethrown from error handler!!", ex);
});
};
break;
}
case ErrorHandlerExample.ContinueWithThrowsEventHandler:
{
exampleDescription = "Solution 6: Continue With inside of Event Handler";
handler += async (s, e) =>
{
await AsyncTaskThatThrowsAsync((ex) =>
{
Console.WriteLine($"[{exampleDescription}] Our exception handler caught aggregate exception: {ex.Message}\n");
foreach (Exception exep in ex.InnerExceptions)
Console.WriteLine($"[{exampleDescription}] inner exception: {exep.Message}\nStack Trace: \n{exep.StackTrace}");
thrownew InvalidOperationException("Rethrown from error handler!!", ex);
});
Console.WriteLine($"[{exampleDescription}] Does anything still run in the event handler after an awaited task blows up?");
};
break;
}
Scenario 5 (the first of the two I added) actually works to protect the event handler. If your continuation doesn't blow up, it successfully keeps execution working properly.
Scenario 6 is almost the same, except that I throw inside the continuation. Annnnd as I expected, because this exception now needs to bubble up and cross that async void boundary, I do truly get an unhandled exception printed out and that extra console writeline inside scenario 6 is never written.
A couple of interesting notes:
- I found out the default behavior for async event handlers is that they ARE run without blocking on the previous ones. It makes sense actually. The event invocation for each handler is not awaited by anything (cannot be since it's void). Not super important here, just something I found out, so thanks!
- Despite having both of those unhandled exception handlers wired up... neither fired even when I truly got an unhandled exception printed out. Insanity!
This was a fun exercise, so thank you for sharing and I hope this was a helpful response even if it was wordy.
I added your code and comments to the example on my repo. Thank you for illuminating these ideas and the new 'unobserved' handler, which was new to me.
I did expect the continuation task to handle the exception. However, I did not expect the 'Exception User-Unhandled' dialog. I hope that the .NET gurus will figure out a way to suppress this dialog without us having to change the IDE settings much.
Note that example 6 does not throw an unhandled exception. Did you expect one?
I prepared some screen shots but I do not see a way to attach files here.
Finally, how would you demonstrate the pitfalls of the void async?
I found example 6 *does* throw an unhandled exception, but I needed to wait longer than 100ms. 10 seconds was super aggressive to run all of those samples, but it definitely gave it more than enough time. I had screenshots to show it happening, but same issue you have (where do we attach them )
Folks that have much more intimate knowledge than I have could explain *why* it happens, but the pitfall of async void is that exceptions are not able to properly "cross the boundary" between async void code and the caller of an async void. I believe this is primarily because async void cannot be awaited (only Tasks can be awaited), so there is no "context" (for lack of a better word here?) that is able to handle the exception.
Fundamentally, all of the solutions I am proposing here (except in my original article[^] that spawned all of this) just ensure that we prevent exceptions from crossing boundary of async void, or avoiding async void all together.