Search Unity

Bug async and uncaught Exceptions

Discussion in 'Scripting' started by RichardWepner, Jan 13, 2021.

  1. RichardWepner

    RichardWepner

    Joined:
    May 29, 2013
    Posts:
    33
    Hello everyone,

    In one of the projects I'm working on, we're using
    async
    and
    await
    in order to execute code asynchronously. (One advantage over Coroutines is the ability to return values.)
    If there occur any exceptions that aren't caught and logged manually, they are dropped silently without any logging done by Unity.

    As a simple example, the following code won't cause an exception:
    Code (CSharp):
    1.  
    2. public class AsyncException : MonoBehaviour
    3. {
    4.     async Task Start()
    5.     {
    6.         await this.ExceptionalTask();
    7.         Debug.LogWarningFormat("asddjlkahsldk");
    8.     }
    9.  
    10.     async Task ExceptionalTask()
    11.     {
    12.         await Task.Delay(2);
    13.         Debug.LogError("Throw dad error!");
    14.         throw new System.NullReferenceException("asdljsldjkfh");
    15.     }
    16. }
    Whereas the following one will log an exception, since I'm explicitly telling it to log an exception:
    Code (CSharp):
    1.  
    2. public class AsyncException : MonoBehaviour
    3. {
    4.     async Task Start()
    5.     {
    6.         try {
    7.             await this.ExceptionalTask();
    8.             Debug.LogWarningFormat("asddjlkahsldk");
    9.         }
    10.         catch(System.Exception exception)
    11.         {
    12.             Debug.LogException(exception);
    13.         }
    14.     }
    15.  
    16.     async Task ExceptionalTask()
    17.     {
    18.         await Task.Delay(2);
    19.         Debug.LogError("Throw dad error!");
    20.         throw new System.NullReferenceException("asdljsldjkfh");
    21.     }
    22. }
    (Just add the component to a GameObject in the Scene and enter the play mode.)

    Is there some setting to enable automatic logging of uncaught exceptions? Is this an oversight in the
    async
    handling in Unity? (Can I expect Unity to add logging of uncaught Exceptions?)
     
  2. Suddoha

    Suddoha

    Joined:
    Nov 9, 2013
    Posts:
    2,824
    That's not a bug.

    Start and other messages are not awaited by the engine, but instead they're just called like normal methods. (Well ok, the exception to that are some messages that are allowed to be declared having an IEnumerator as return value, which let's Unity know you want to execute them as a coroutine directly)

    Back to the Tasks... So since Start is called "synchronously", the exception would bubble up once more, but at that point, there's no more an async context that allows you to handle exceptions from awaitables in any way. It just disappears into the nirvana.

    Instead, treat Start as some sort of entry point / root level at which you can start to kick off new async functionality and declare it as
    async void Start()
    .
     
  3. organick

    organick

    Joined:
    Jul 17, 2012
    Posts:
    17
    I'm seeing the same behaviour with uncaught exceptions when using the signature:
    Code (CSharp):
    1. async Task Update()
    I'm not sure I follow Suddoha's explanation above. Why would the signature that returns a Task not handle exceptions? As a consumer of the engine, I found it much more intuitive for the signature that returns a Task to be able to handle exceptions.

    Code (CSharp):
    1. async Task Update() // doesn't handle exception
    2. async void Update() // handles exception
    It would be nice if at the very least there's some minimum documentation on the expected behaviour of the two different signatures (or anything at all about how async/await is handled by Unity).
     
  4. Flying_Banana

    Flying_Banana

    Joined:
    Jun 19, 2019
    Posts:
    29
    I recently upgraded to 2021.2. While I was in 2020.2, I had no problem with await and exceptions. However, I noticed they are all swallowed silently now. This is making debugging a lot slower.

    I think this is a bug.
     
  5. marcospgp

    marcospgp

    Joined:
    Jun 11, 2018
    Posts:
    194
    I could reproduce this issue just now, using Unity version 2020.3.33f1.


    Code (CSharp):
    1.         public async void Start() {
    2.             Debug.Log("Test");
    3.             Task.Run(() => {
    4.                 throw new System.NotImplementedException("test");
    5.             });
    6.  
    7.             await Task.Run(() => {
    8.                 throw new System.NotImplementedException("test 2");
    9.             });
    10. }
    The code above logs "Test", then the exception "test 2".

    Code (CSharp):
    1.         public async Task Start() {
    2.             Debug.Log("Test");
    3.             Task.Run(() => {
    4.                 throw new System.NotImplementedException("test");
    5.             });
    6.  
    7.             await Task.Run(() => {
    8.                 throw new System.NotImplementedException("test 2");
    9.             });
    10. }
    The code above only logs "Test".

    That's some weird behavior!

    An aside: Does someone know how I could have all exceptions (including the non awaited ones above) be logged to the console?

    Update: I tried doing this

    Code (CSharp):
    1. using System;
    2. using UnityEngine;
    3.  
    4. namespace MarcosPereira.Utility {
    5.     public static class UnhandledExceptionHandler {
    6.         [RuntimeInitializeOnLoadMethod]
    7.         private static void OnLoad() {
    8.             AppDomain.CurrentDomain.UnhandledException +=
    9.                 (object sender, UnhandledExceptionEventArgs args) =>
    10.                     Debug.LogError((Exception) args.ExceptionObject);
    11.         }
    12.     }
    13. }
    14.  
    but it makes no difference at all.
     
    Last edited: May 1, 2022
  6. Wepner-PD

    Wepner-PD

    Joined:
    Jul 13, 2018
    Posts:
    32
    @marcospgp: My current working theory is as follows:
    If an exception is thrown before the first
    await
    was hit, the exception is thrown directly before a
    Task
    is returned, and it's handled the same way it would otherwise be handled.
    After the first
    await
    (or in your example right from the beginning in any method executed using
    Task.Run([...])
    ), if the method returns a
    Task
    , the exception is attached to the
    Task
    to be evaluated later on once the
    Task
    gets
    await
    ed, and otherwise it can't be attached and thus not triggered by an
    await
    and Unity knows that it has to handle it.
    If an exception is attached to a
    Task
    , then it could happen at some point in the future that it will be
    await
    ed, but it might not have happened, since multiple
    Task
    s are waited for at the same time, including the one with the exception. Just handling this exception automatically by Unity could cause it to be handled later on again, leading to duplicated log messages. Or it might already be handled by the code, and then there's an error log that must be ignored, which is not a good approach.

    If this is the case, there might not be a good approach for the environment to deal with this. except for the garbage collection. If a
    Task
    with an exception is collected, and has an exception attached to it, and never got
    await
    ed, then it would be safe to log an error message. This would still have the disadvantage that the logging happens delayed to the actual occurrence of the exception, but it would be better than the current approach. This would require though that the differentiation between
    await
    ed (i. e. already handled) or not is possible at all.
     
    marcospgp likes this.
  7. marcospgp

    marcospgp

    Joined:
    Jun 11, 2018
    Posts:
    194
    I first learned about async await in the context of javascript promises, where uncaught exceptions are never swallowed into oblivion. I don't think there's ever any scenario where implicitly ignoring an exception makes sense, so I'm surprised a simple missed
    await
    can cause that behavior.

    To add to the confusion, it seems Unity has its own custom SynchronizationContext (still not fully clear on what those are) that causes tasks to run on the main thread (so basically async await becomes a non-hacky coroutine?)

    It's not clear to me whether Task.Run() forces async code to run on a separate thread, so I have to do some more research which eats into precious dev time.

    Async await is so elegant, why did Unity have to go with the hacky C# jobs follow up to the hacky coroutine (hijacking IEnumerators for pseudo parallel programming)? :(
     
    Last edited: May 6, 2022
  8. Cameron_SM

    Cameron_SM

    Joined:
    Jun 1, 2009
    Posts:
    915
    This should be expected behaviour as you're not trying to catch any exceptions there. You should also know that tasks can eat exceptions if they're not awaited. This is just how async code behaves in C#. Exceptions are passed to awaited tasks so higher level call sites can handle them. If you're writing async code and don't understand how exceptions work then it's worth spending some time learning.

    Although, for game code, you probably want to avoid exceptions and use a light weight error result instead. Only use exceptions for truly fatal errors that aren't expected (you don't have code to recover form gracefully) and thus should just exit the program. For expected errors (API timeout etc, purchase fail etc) you really don't want the performance overhead of triggering a full stack trace which is what every exception till trigger when being created. Exceptions are for "crap that should never have happened I want the engineers to know all the possible debug and state into in the world right so we can fix this" kind of fatal errors.

    Why you'd write start/update to return a task is a mystery though - the Unity engine isn't going to await them, they should always be async void as they will be fire/forget. You may have read on the internet that this is "bad" and are thus upset Unity are doing this, if that's the case, I recommend reading more in depth about what the compiler actually does with async and await. There's some good articles around that break it down and show the underlying IL code that's generated and give good explanations as to what's going on.

    You should know what a synchronisation context is if you're using async/await. 15 minutes of reading will save you many more hours in debugging code you wrote when not thinking about how it might be important to synchronise their 3rd async API service request back to the main thread. Makes sense to me that Unity might one comes ones, it's a multi threaded engine after all. You also don't seem to understand what coroutines do under the hood either. It's not complex but worth knowing.

    Coroutines were a well established pattern when unity implemented them way back in Unity 3.0 on early versions of the mono scripting engine, way before async in .net. You could even write your own coroutine systems using IEnumerator and yield - when you understand them well you'll see they're really more of a state machine than anything (same with async/await).

    Jobs solves a completely different problem to C# asyc. Jobs is about achieving maximum performance though data-oriented programming. It's not your daddy's async and not something a managed language like C# is even capable of. Jobs are all compiled to native code to make the data being processed hyper cache friendly/optimised so you really hone in on cutting out any wasted CPU cycles. Tech like that is how they can get a PS4 to turn out visuals like Spiderman, in fact the technical leads from Insomniac who worked on Spiderman (Mike Acton and Andreas Frederiksson) joined Unity to work on Jobs and DOTs. Unity seemingly dropped the ball on getting them in sync with the rest of the engine team but I still wouldn't trash it, it's movement in a direction Unity didn't even have on the roadmap before they joined.

    In the future try doing a little reading on the systems you're having trouble understanding - knowing the history and why of things will make you a much better engineer and probably save you a lot of dev time too.
     
  9. marcospgp

    marcospgp

    Joined:
    Jun 11, 2018
    Posts:
    194
    "This is just how it is" isn't a great explanation, and doesn't refute how implicitly swallowing exceptions is not good or even expected behavior.

    How many exceptions are you planning to handle per frame? And why would you sacrifice logging in exchange for (an unclear gain in) performance in the editor?

    Exceptions can be caught and handled if they are expected to occur, too.

    It's to test if there's a difference in exception throwing behavior. And the result is unexpected, since returning a Task causes exceptions to be swallowed - when one expects the void returning methods to swallow exceptions instead.

    I have done a lot more than 15 minutes of reading, but still don't know whether code inside a
    Task.Run()
    runs under Unity's SynchronizationContext (and is thus single threaded) or if it is handled by C#'s default context. Perhaps you could help me?

    I understand that it's an old concept, but using enumerators - which define how a collection should be traversed - as a (single threaded!) parallel programming paradigm is not at all elegant. It makes no sense to ignore async/await and keep overloading enumerators with that functionality.

    No they don't. They're about writing multithreaded code, except in a much more restrictive way - which comes as a result of a bigger focus on optimization, I understand. But having to define a struct for each multithreaded task is one scary small step behind recreating UnityScript.

    Thank you for the kind advice!
     
  10. Cameron_SM

    Cameron_SM

    Joined:
    Jun 1, 2009
    Posts:
    915
    The explication is sometimes in the Microsoft documentation, sometimes in the blogs of the engineers who work on the language features. I agree the details of exception handling with async are not great, Microsoft maybe could have done a better job but at the same time, the people creating these language features tend to be a lot smarter than me and are often away of 100 things I never even though of as being a problem untill I dig into one of their blog posts and normally come away with a vague "ok so this being a bit weird means I don't need to care about those 50 other things when writing async code, cool, I can live with that".

    The gain is explicitly clear. Any exception is going to hit the GC and cause dropped frames in a mobile game or on Quest. Using exceptions for expected errors is actually exception flow control and considered an anti-pattern. This is why so many frameworks use an error object in the API response instead of throwing an exception. Also, when you work on projects with 20+ engineers you can end up with a lot of systems throwing exceptions all over for results that are expected and don't need expensive stack traces. I think it's just a good habit that'll serve most people well if they want a long career in game engineering. Most of the time you shouldn't care much about performance until you profile but exceptions should maybe be the exception (pardon the pun) when it comes to games or rolling our an API services. A single exception in the wrong place in a service layer on a server could cost a lot of real money when you scale up to billions of requests a day. Likewise, a single exception in your quest game could see your game's rating drop from Comfortable (consistent 90fps without hitches) to Moderate.

    Not sure what that means but generally agree exception handling with tasks is crappy. I'm not sure Unity can really do anything about that though and even if they could, they probably shouldn't diverge from the C# spec.

    Starting a new task queues that task for execution on a threadpool thread. Threads execute in the context of the application - so Unity has a main application thread and starting a task from it creates a context for which threads in that Unity instance will run. The main unity thread will continue on with work while your task runs on a thread in the context of your application. The synchronisation context can be used to tell the task which thread to synchronise with when it's work is done (when control returns to the call site) - be it the main thread or some other context.

    As in, what thread should pick up control and continue executing the code directly after the awaited call once the task is completed (or faulted). Sometimes you want the main thread, sometimes you want the calling context (possibly a thread pool), sometimes you may want something else (jobs thread pool? dedicated rendering or physics thread?).

    Enumerators are a language feature, the concept of yielding execution is the core of coroutines, ignore iteration and collections. Yield suspects execution till a later time, a feature present in many different languages stretching back to the 1960s. Yield is powerful when well understood. Coroutines provide concurrency but not parallelism. Parallelism was ~never~ their intent and they existed in Unity way before async support was possible in the scripting engine. I'm honestly not sure why you're angry about them, if Unity removed them millions of games would no longer compile. If you don't like Coroutines don't use them but it seems to me you don't really understand them well enough to decide if they would or would not be a good fit for a particular use case.

    Sure, some Unity APIs use coroutines when they probably should use Tasks, but that tends to be service level stuff that I always want a layer of my own code in front of anyway and then you can just wrap the coroutine with your prefer flavor of handling async flow control.

    There are also other concepts for handling concurrency with and without parallelism. I've a well tested C# promise library (futures pattern / monads) that helps with asynchronous flow control via a nice fluid API. It's entirely single threaded and that's fine. Tasks are also essentially promises/futures that can also be multithreaded but having a task that results in different flows based on the task result produces kinda messy code so I still use that single threaded promise library a lot because not all code actually needs to be multi threaded - Monads are a perfectly fine solution worth learning about.

    Not sure I understand how you see Data orientated design/programming as the same as c# async. They're different ideas with different goals. I think it's ok for both ideas to coexist - not all games are alike and have the same performance requirements or work on the same kinds of data. Maybe we just agree to disagree here.
     
    Last edited: May 3, 2022
    orionsyndrome and marcospgp like this.
  11. marcospgp

    marcospgp

    Joined:
    Jun 11, 2018
    Posts:
    194
  12. marcospgp

    marcospgp

    Joined:
    Jun 11, 2018
    Posts:
    194
    I built a wrapper around Task.Run() that

    * Ignores the result of tasks if play mode is exited while they are running;
    * Forces exceptions to be logged to the console, even if the task is not awaited.

    I wrote a small blog post and shared the code here.

    Note that ignoring the result is enough to avoid most issues of allowing tasks to keep running outside play mode, as Unity APIs are only accessible after the
    await
    , when execution returns to the main thread (due to Unity's custom SynchronizationContext).

    Looking back at Unity's SynchronizationContext, maybe the reason these unawaited tasks have their exceptions swallowed is this try with no catch.
     
    Last edited: May 8, 2022
  13. marcospgp

    marcospgp

    Joined:
    Jun 11, 2018
    Posts:
    194
    New observation: I got

    Code (CSharp):
    1. TaskScheduler.UnobservedTaskException +=
    2.                 (_, e) => UnityEngine.Debug.LogException(e.Exception);
    to work, but it seems like the handler only fires after scripts are reloaded in the editor. So I won't see an issue, but then if I change a script I will see the previously thrown exception in the console.

    I tried calling
    System.GC.Collect();
    to see if it would fire right away, but it doesn't change anything.

    Update: If I call

    Code (CSharp):
    1. await Task.Delay(1000);
    2.  
    3. System.GC.Collect();
    the exception is indeed logged without having to reload scripts. So forcing garbage collection does cause unobserved tasks to be handled!

    So I updated my SafeTask wrapper accordingly: https://gist.github.com/marcospgp/291a8239f5dcb1a326fad37d624f3630
     
    Last edited: May 13, 2022
    customphase, DragonCoder and ltomov like this.
  14. Maeslezo

    Maeslezo

    Joined:
    Jun 16, 2015
    Posts:
    331
    This seems to be the root of the problem. I'm trying to understand why is that but I couldn't find any information.
    If someone has any relevant information (article, post, book ref, etc), please share.
     
  15. Maeslezo

    Maeslezo

    Joined:
    Jun 16, 2015
    Posts:
    331
  16. SF_FrankvHoof

    SF_FrankvHoof

    Joined:
    Apr 1, 2022
    Posts:
    780
    If an exception is thrown inside a task, it's scoped to that task. Thus, the Main Unity Thread (which started that task) will have no idea about it.
     
  17. xucian

    xucian

    Joined:
    Mar 7, 2016
    Posts:
    846
    For anyone struggling with tasks, I'm throwing in my AsyncRoot utility that I'm using everywhere to start new tasks from non-async contexts, and safely log their exceptions.
    Uses UniTask for most of its functionalities. Been using if for over 1 year with success, true life saver.

    Updated code:
    Code (CSharp):
    1.  
    2. using System;
    3. using System.Threading;
    4. using System.Threading.Tasks;
    5. using UnityEngine;
    6. using Cysharp.Threading.Tasks;
    7. using LibCore.Sys.Logging;
    8. namespace LibCore.Threading
    9. {
    10.     /// <summary>
    11.     /// Helper to wrap execution of UniTasks from a sync method while handling any exceptions.
    12.     /// The pattern "DoAsync().Forget()" also works, but centralizing these calls might prove useful in the future
    13.     /// TODO run all tasks as bound, but those that don't explicitly request that will be bound to a unique, persistent, hidden-in-hierarchy gameobject, so as to minimize dangling tasks from leaking from playmode
    14.     /// </summary>
    15.     public static class AsyncRoot
    16.     {
    17.         static AsyncRoot()
    18.         {
    19.             TaskScheduler.UnobservedTaskException += TaskScheduler_UnobservedTaskException;
    20.         }
    21.         public static void RunForget(Func<UniTask> taskFactory) => _ = Run(taskFactory);
    22.         public static Task Run(Func<UniTask> taskFactory)
    23.         {
    24.             var task = taskFactory().AsTask();
    25.             task.ContinueWith(LogException, TaskContinuationOptions.OnlyOnFaulted);
    26.             return task;
    27.         }
    28.         public static Task<T> Run<T>(Func<UniTask<T>> taskFactory)
    29.         {
    30.             var task = taskFactory().AsTask();
    31.             task.ContinueWith(LogException, TaskContinuationOptions.OnlyOnFaulted);
    32.             return task;
    33.         }
    34.         /// <summary>
    35.         /// For running bound to an object use GetCancellationTokenOnDestroy() extension at task creation and then the standard Task.Run(.., ..)
    36.         /// </summary>
    37.         /// <param name="taskFactory"></param>
    38.         public static Task<T> Run<T>(Func<Task<T>> taskFactory)
    39.         {
    40.             var task = taskFactory();
    41.             task.ContinueWith(LogException, TaskContinuationOptions.OnlyOnFaulted);
    42.             return task;
    43.         }
    44.         public static Task Run(Func<Task> taskFactory)
    45.         {
    46.             var task = taskFactory();
    47.             task.ContinueWith(LogException, TaskContinuationOptions.OnlyOnFaulted);
    48.             return task;
    49.         }
    50.         public static void RunBound(GameObject boundTo, Func<UniTask> taskFactory)
    51.         {
    52.             RunInternal(boundTo.GetCancellationTokenOnDestroy(), taskFactory);
    53.         }
    54.         public static void RunBound(Component boundTo, Func<UniTask> taskFactory)
    55.         {
    56.             RunInternal(boundTo.GetCancellationTokenOnDestroy(), taskFactory);
    57.         }
    58.         // Not tested
    59.         public static Task<(bool isCancelled, T result)> RunBound<T>(Component boundTo, Func<UniTask<T>> taskFactory)
    60.         {
    61.             return RunInternal(boundTo.GetCancellationTokenOnDestroy(), taskFactory);
    62.         }
    63.         /// <summary>
    64.         /// Note you need to check for <see cref="continueWith.IsFaulted"/> before assuming success
    65.         /// </summary>
    66.         public static void RunBound(GameObject boundTo, Func<UniTask> taskFactory, Action<Task> continueWith)
    67.         {
    68.             RunInternal(boundTo.GetCancellationTokenOnDestroy(), taskFactory, continueWith);
    69.         }
    70.         /// <summary>
    71.         /// Note you need to check for <see cref="continueWith.IsFaulted"/> before assuming success
    72.         /// </summary>
    73.         public static void RunBound(Component boundTo, Func<UniTask> taskFactory, Action<Task> continueWith)
    74.         {
    75.             RunInternal(boundTo.GetCancellationTokenOnDestroy(), taskFactory, continueWith);
    76.         }
    77.         static void RunInternal(CancellationToken cancellationToken, Func<UniTask> taskFactory, Action<Task> continueWith)
    78.         {
    79.             RunInternal(cancellationToken, taskFactory, continueWith, TaskContinuationOptions.None);
    80.         }
    81.         // Not tested
    82.         static Task<(bool isCancelled, T result)> RunInternal<T>(CancellationToken cancellationToken, Func<UniTask<T>> taskFactory, Action<Task> continueWith)
    83.         {
    84.             return RunInternal(cancellationToken, taskFactory, continueWith, TaskContinuationOptions.None);
    85.         }
    86.         static void RunInternal(CancellationToken cancellationToken, Func<UniTask> taskFactory)
    87.         {
    88.             RunInternal(cancellationToken, taskFactory, LogException, TaskContinuationOptions.OnlyOnFaulted);
    89.         }
    90.         // Not tested
    91.         static Task<(bool isCancelled, T result)> RunInternal<T>(CancellationToken cancellationToken, Func<UniTask<T>> taskFactory)
    92.         {
    93.             return RunInternal(cancellationToken, taskFactory, LogException, TaskContinuationOptions.OnlyOnFaulted);
    94.         }
    95.         static void RunInternal(CancellationToken cancellationToken, Func<UniTask> taskFactory, Action<Task> continueWith, TaskContinuationOptions continuationOptions)
    96.         {
    97.             var task = taskFactory()
    98.                 .AttachExternalCancellation(cancellationToken)
    99.                 .SuppressCancellationThrow()
    100.                 .AsTask();
    101.             task.ContinueWith(continueWith, continuationOptions);
    102.         }
    103.         // Not tested
    104.         static Task<(bool isCancelled, T result)> RunInternal<T>(CancellationToken cancellationToken, Func<UniTask<T>> taskFactory, Action<Task> continueWith, TaskContinuationOptions continuationOptions)
    105.         {
    106.             var task = taskFactory()
    107.                 .AttachExternalCancellation(cancellationToken)
    108.                 .SuppressCancellationThrow()
    109.                 .AsTask();
    110.             task.ContinueWith(continueWith, continuationOptions);
    111.             return task;
    112.         }
    113.         /// <summary>
    114.         /// IMPORTANT: Using LogException because throwing doesn't work on non-main thread (might try calling a dispatcher, but letting it as it is for now)
    115.         /// </summary>
    116.         /// <param name="task"></param>
    117.         static void LogException(Task task)
    118.         {
    119.             Debug.LogException(task.Exception);
    120.         }
    121.         static void TaskScheduler_UnobservedTaskException(object sender, UnobservedTaskExceptionEventArgs e)
    122.         {
    123.             // This is called on GC, if the tasks didn't have a ContinueWith for Failure, nor had anyone handling their exceptions.
    124.             // This shouldn't normally happen if all tasks are started from this class, but it's good to log them
    125.             L.Deb(e.Exception);
    126.         }
    127.     }
    128. }
    129.  

    Original code (ignore it, use the above pls):
    Code (CSharp):
    1. using System;
    2. using System.Threading;
    3. using System.Threading.Tasks;
    4. using UnityEngine;
    5. using Cysharp.Threading.Tasks;
    6.  
    7. namespace LibCore.Threading
    8. {
    9.     /// <summary>
    10.     /// Helper to wrap execution of UniTasks from a sync method while handling any exceptions.
    11.     /// The pattern "DoAsync().Forget()" also works, but centralizing these calls might prove useful in the future
    12.     /// TODO run all tasks as bound, but those that don't explicitly request that will be bound to a unique, persistent, hidden-in-hierarchy gameobject, so as to minimize dangling tasks from leaking from playmode
    13.     /// </summary>
    14.     public static class AsyncRoot
    15.     {
    16.         public static void RunForget(Func<UniTask> taskFactory) => _ = Run(taskFactory);
    17.  
    18.         public static Task Run(Func<UniTask> taskFactory)
    19.         {
    20.             var task = taskFactory().AsTask();
    21.             task.ContinueWith(LogException, TaskContinuationOptions.OnlyOnFaulted);
    22.  
    23.             return task;
    24.         }
    25.  
    26.         public static Task<T> Run<T>(Func<UniTask<T>> taskFactory)
    27.         {
    28.             var task = taskFactory().AsTask();
    29.             task.ContinueWith(LogException, TaskContinuationOptions.OnlyOnFaulted);
    30.  
    31.             return task;
    32.         }
    33.  
    34.         /// <summary>
    35.         /// For running bound to an object use GetCancellationTokenOnDestroy() extension at task creation and then the standard Task.Run(.., ..)
    36.         /// </summary>
    37.         /// <param name="taskFactory"></param>
    38.         public static Task<T> Run<T>(Func<Task<T>> taskFactory)
    39.         {
    40.             var task = taskFactory();
    41.             task.ContinueWith(LogException, TaskContinuationOptions.OnlyOnFaulted);
    42.  
    43.             return task;
    44.         }
    45.         public static Task Run(Func<Task> taskFactory)
    46.         {
    47.             var task = taskFactory();
    48.             task.ContinueWith(LogException, TaskContinuationOptions.OnlyOnFaulted);
    49.  
    50.             return task;
    51.         }
    52.  
    53.         public static void RunBound(GameObject boundTo, Func<UniTask> taskFactory)
    54.         {
    55.             RunInternal(boundTo.GetCancellationTokenOnDestroy(), taskFactory);
    56.         }
    57.  
    58.         public static void RunBound(Component boundTo, Func<UniTask> taskFactory)
    59.         {
    60.             RunInternal(boundTo.GetCancellationTokenOnDestroy(), taskFactory);
    61.         }
    62.  
    63.         // Not tested
    64.         public static Task<(bool isCancelled, T result)> RunBound<T>(Component boundTo, Func<UniTask<T>> taskFactory)
    65.         {
    66.             return RunInternal(boundTo.GetCancellationTokenOnDestroy(), taskFactory);
    67.         }
    68.  
    69.         /// <summary>
    70.         /// Note you need to check for <see cref="continueWith.IsFaulted"/> before assuming success
    71.         /// </summary>
    72.         public static void RunBound(GameObject boundTo, Func<UniTask> taskFactory, Action<Task> continueWith)
    73.         {
    74.             RunInternal(boundTo.GetCancellationTokenOnDestroy(), taskFactory, continueWith);
    75.         }
    76.  
    77.         /// <summary>
    78.         /// Note you need to check for <see cref="continueWith.IsFaulted"/> before assuming success
    79.         /// </summary>
    80.         public static void RunBound(Component boundTo, Func<UniTask> taskFactory, Action<Task> continueWith)
    81.         {
    82.             RunInternal(boundTo.GetCancellationTokenOnDestroy(), taskFactory, continueWith);
    83.         }
    84.  
    85.         static void RunInternal(CancellationToken cancellationToken, Func<UniTask> taskFactory, Action<Task> continueWith)
    86.         {
    87.             RunInternal(cancellationToken, taskFactory, continueWith, TaskContinuationOptions.None);
    88.         }
    89.  
    90.         // Not tested
    91.         static Task<(bool isCancelled, T result)> RunInternal<T>(CancellationToken cancellationToken, Func<UniTask<T>> taskFactory, Action<Task> continueWith)
    92.         {
    93.             return RunInternal(cancellationToken, taskFactory, continueWith, TaskContinuationOptions.None);
    94.         }
    95.  
    96.         static void RunInternal(CancellationToken cancellationToken, Func<UniTask> taskFactory)
    97.         {
    98.             RunInternal(cancellationToken, taskFactory, LogException, TaskContinuationOptions.OnlyOnFaulted);
    99.         }
    100.  
    101.         // Not tested
    102.         static Task<(bool isCancelled, T result)> RunInternal<T>(CancellationToken cancellationToken, Func<UniTask<T>> taskFactory)
    103.         {
    104.             return RunInternal(cancellationToken, taskFactory, LogException, TaskContinuationOptions.OnlyOnFaulted);
    105.         }
    106.  
    107.         static void RunInternal(CancellationToken cancellationToken, Func<UniTask> taskFactory, Action<Task> continueWith, TaskContinuationOptions continuationOptions)
    108.         {
    109.             var task = taskFactory()
    110.                 .AttachExternalCancellation(cancellationToken)
    111.                 .SuppressCancellationThrow()
    112.                 .AsTask();
    113.             task.ContinueWith(continueWith, continuationOptions);
    114.         }
    115.  
    116.         // Not tested
    117.         static Task<(bool isCancelled, T result)> RunInternal<T>(CancellationToken cancellationToken, Func<UniTask<T>> taskFactory, Action<Task> continueWith, TaskContinuationOptions continuationOptions)
    118.         {
    119.             var task = taskFactory()
    120.                 .AttachExternalCancellation(cancellationToken)
    121.                 .SuppressCancellationThrow()
    122.                 .AsTask();
    123.  
    124.              task.ContinueWith(continueWith, continuationOptions);
    125.  
    126.             return task;
    127.         }
    128.  
    129.         /// <summary>
    130.         /// IMPORTANT: Using LogException because throwing doesn't work on non-main thread (might try calling a dispatcher, but letting it as it is for now)
    131.         /// </summary>
    132.         /// <param name="task"></param>
    133.         static void LogException(Task task)
    134.         {
    135.             Debug.LogException(task.Exception);
    136.         }
    137.     }
    138. }
     
    Last edited: Nov 29, 2023
  18. RichardWepner

    RichardWepner

    Joined:
    May 29, 2013
    Posts:
    33
    I was watching the Video you linked, and I guess it's not fully applicable to a Unity project. In general, there are 3 cases that were mentioned in the video:
    1. properly used
      async
      and
      await
      - Exceptions behave as expected (also in Unity)
    2. Unhandled Exceptions with
      async void
      - in Unity, these are just getting logged similar to regular exceptions that you're not catching, and this is actually sometimes more desirable than
      async Task
    3. Unhandled Exceptions with
      async Task
      (i. e. no
      await
      and other state checking of the Task) - any exception will just be "attached" to the Task just as shown in the video, but without any indication that something went wrong (the exception might get logged once the Task gets garbage collected)
    So in summary, point 1 is no problem either way, point 2 doesn't apply to Unity because it doesn't have the same impact, and point 3 should be more important than described in the video, since you won't notice that something is wrong, and you might only figure it out if you detect that some things in the game are just not happening as expected (without exceptions being logged, of course).

    Regarding
    async void
    : in my opinion it's better to get exceptions logged in Unity than not getting them logged, and Unity messages (Awake, Start, ...) can have
    Task
    as return value, but the resulting
    Task
    will be ignored by Unity (i. e. no logging). Maybe you should deal with starting asynchronous code from Awake and Start in a slightly different manner (i. e. only call an
    async
    method, store the Task and check the state of the task later), but for simple or event some "quick and dirty" code,
    async void
    would be the better option here. (Note: I'm really only talking about the special case of Unity messages. Other methods should avoid
    async void
    since the caller will not be able to deal with any kinds of errors.)
     
  19. Maeslezo

    Maeslezo

    Joined:
    Jun 16, 2015
    Posts:
    331
    You are right. I didn't mean to say it was 100% applicable to Unity, I just find them interesting resources to understand the context of the topic.
     
  20. ltomov

    ltomov

    Joined:
    Aug 3, 2017
    Posts:
    96
    This actually solved my problem and I can now see the previously swallowed exceptions from unawaited async functions.

    Is there any situation where this would fail? I don't understand the need to use other, more complex code, instead of just calling the above at the start of the app?
     
    xucian likes this.
  21. xucian

    xucian

    Joined:
    Mar 7, 2016
    Posts:
    846
    Thanks for this, I actually had this too in my code, not sure where from, but the code I posted above didn't have it.
    I just updated it in case anyone needs a similar tool.
    @ltomov you're right, that is enough to just log the exception and that's the most important thing. What my script does is just giving you more options when starting async code from non-async code.
    Using tasks inherently means starting some async work from a non-async place (everything has to start somewhere), and that's the reason for why I wanted to standardize the way I'm doing it. It's also nice when I'll need to change some common behavior in all my sync-to-async initiations
     
    ltomov likes this.
  22. CodeRonnie

    CodeRonnie

    Joined:
    Oct 2, 2015
    Posts:
    531
    Only in Unity. In the environment that async was designed for, traditional .NET applications, you are supposed to go async all the way down to the root of the application. You are supposed to use an async Main() method, BackgroundService.ExecuteAsync(), and similar async event methods. Those apps are not loop based, they are event based, and you're supposed to just fill out the async version of the relevant methods provided anywhere that you actually use any async code. Calling async code from synchronous code and not awaiting it is actually bad practice, and not recommended. That's one of the reasons I don't like the design of Task based async. It assumes that there will always be a root async method provided for you, which is not the case in Unity. .NET code often makes narrow assumptions like that which only apply to their expected working environment, like an ASP.NET or enterprise app, but it's not universal enough for every application. You're right that it must start somewhere, but the Microsoft devs seem hell bent on hiding that start away for your convenience and insisting that you should never actually do it yourself. So, it's difficult to find good information on best practices when Unity doesn't work that way.
     
    xucian likes this.
  23. Flying_Banana

    Flying_Banana

    Joined:
    Jun 19, 2019
    Posts:
    29
    For anyone still struggling on this, I highly recommend checking out UniTask, the open source drop-in replacement for Task, specifically written for Unity.

    It runs on the player loop, and automatically surfaces exceptions if you don't catch it, after a few seconds.
     
    CodeRonnie likes this.
  24. Tortuap

    Tortuap

    Joined:
    Dec 4, 2013
    Posts:
    137
    To anyone landing on this forum thread wondering why Exceptions that are raised in their async/await methods using Awaitable introduced in Unity 2023, are not logged, this is the behaviour to expect when using void async, as well as non awaited Awaitable.

    Exceptions are trapped by the Awaitable, and are not magically logged by anyone.

    One correct way to get exceptions logged is to use async event methods ( async Start, async Awake, etc. ) like so:

    Code (CSharp):
    1.  
    2. async Awaitable TestAsync ()
    3. {
    4.     throw new Exception ( "Test of exception" );
    5.     await Awaitable.NextFrameAsync ();
    6. }
    7.  
    8. protected async void Start ()
    9. {
    10.     await TestAsync (); // exception is caught & logged by Unity code that execute async Start
    11. }
    12.  
    Another way, if called from a non async method, is to wait for its completion like this :

    Code (CSharp):
    1.  
    2. protected void Start ()
    3. {
    4.     TestAsync ().GetAwaiter ().GetResult (); // wait for completion, GetResult executes PropagateExceptionAndRelease which raise the exception trapped in the Awaitable
    5. }
    6.  
    Or, finally, without waiting for its completion, to do something like :


    Code (CSharp):
    1.  
    2. public static class Awaitables
    3. {
    4.     [MethodImpl ( MethodImplOptions.AggressiveInlining )]
    5.     public static Awaitable Run ( this Awaitable self )
    6.     {
    7.         self.GetAwaiter ().OnCompleted ( () => self.GetAwaiter ().GetResult () );
    8.         return self;
    9.     }
    10. }
    11.  
    12. protected void Start ()
    13. {
    14.     Awaitables.Run ( TestAsync() ); // don't wait for it, on completion, will call .GetAwaiter().GetResult() which will executes PropagateExceptionAndRelease which raise the exception trapped in the Awaitable
    15. }
    16.  
     
    Last edited: Jan 24, 2024
    mockah likes this.
  25. xucian

    xucian

    Joined:
    Mar 7, 2016
    Posts:
    846
    Nice, I didn't know that. Thanks!
    Makes sense, native .NET devs always have the easiest lives!

    Yep

    One note here: I think
    .GetAwaiter ().GetResult ()
    would still not work on WebGL (it'll block the whole app)