4

I am using Steven Toub's excellent AsyncPump class that allows console applications to use the async/await keywords.

However, I have a problem where exceptions that are thrown in the code are caught by the pump and then rethrown, which causes the original call stack and exception context to be lost.

Here is my test code:

class Program
{
  static void Main(string[] arg)
  {
    AsyncPump.Run(() => MainAsync());
  }

  static async Task MainAsync()
  {
    throw new Exception(); // code should break here
  }
}

If you run this test, the debugger doesn't break on the throw new Exception() as desired. Instead, it breaks on t.GetAwaiter().GetResult(), which is part of the AsyncPump class itself. This makes debugging applications very difficult.

Is there any way to rethrow exceptions such that the debugger breaks at the original location while preserving the call stack and context?

Mike
  • 7,500
  • 8
  • 44
  • 62

2 Answers2

2

GetAwaiter().GetResult() is already rethrowing exceptions properly (assuming you're on .NET 4.5). The call stack is properly preserved.

What you're observing is the behavior of a top-level exception being caught, and AFAIK it is strictly treated by VS as synchronous and there's no way to influence that. Sounds like it would make a good UserVoice item.

You do have the option of breaking when an exception is thrown.

Stephen Cleary
  • 437,863
  • 77
  • 675
  • 810
  • You say `Ad-hoc message pumps do not work in every situation. E.g., any API that requires a particular SynchronizationContext will fail. Some classic ASP.NET APIs are like this; they silently deadlock if you use a custom SynchronizationContext. As another example, any method that runs on a UI thread and depends on STA COM marshalling would deadlock.` – PreguntonCojoneroCabrón Mar 31 '18 at 08:22
2

You would probably see the desired behavior if you used async void signature for MainAsync, rather than async Task. This doesn't mean you should change your code (async void is almost never a good idea), it just means that the existing behavior is perfectly normal.

An exception thrown from async Task methods is not re-thrown immediately. Rather, it is stored inside the Task object (with the captured stack context) and will be re-thrown when the task's result gets observed via task.Result, task.Wait(), await task or task.GetAwaiter().GetResult().

I posted a bit more detailed explanation of this: TAP global exception handler.

On a side note, I use a slightly modified version of AsyncPump, which makes sure the initial task starts executing asynchronously (i.e., after the core loop has started pumping), with TaskScheduler.Current being TaskScheduler.FromCurrentSynchronizationContext():

/// <summary>
/// PumpingSyncContext, based on AsyncPump
/// http://blogs.msdn.com/b/pfxteam/archive/2012/02/02/await-synchronizationcontext-and-console-apps-part-3.aspx
/// </summary>
class PumpingSyncContext : SynchronizationContext
{
    BlockingCollection<Action> _actions;
    int _pendingOps = 0;

    public TResult Run<TResult>(Func<Task<TResult>> taskFunc, CancellationToken token = default(CancellationToken))
    {
        _actions = new BlockingCollection<Action>();
        SynchronizationContext.SetSynchronizationContext(this);
        try
        {
            var scheduler = TaskScheduler.FromCurrentSynchronizationContext();

            var task = Task.Factory.StartNew(
                async () =>
                {
                    OperationStarted();
                    try
                    {
                        return await taskFunc();
                    }
                    finally
                    {
                        OperationCompleted();
                    }
                },
                token, TaskCreationOptions.None, scheduler).Unwrap();

            // pumping loop
            foreach (var action in _actions.GetConsumingEnumerable())
                action();

            return task.GetAwaiter().GetResult();
        }
        finally
        {
            SynchronizationContext.SetSynchronizationContext(null);
        }
    }

    void Complete()
    {
        _actions.CompleteAdding();
    }

    // SynchronizationContext methods
    public override SynchronizationContext CreateCopy()
    {
        return this;
    }

    public override void OperationStarted()
    {
        // called when async void method is invoked 
        Interlocked.Increment(ref _pendingOps);
    }

    public override void OperationCompleted()
    {
        // called when async void method completes 
        if (Interlocked.Decrement(ref _pendingOps) == 0)
            Complete();
    }

    public override void Post(SendOrPostCallback d, object state)
    {
        _actions.Add(() => d(state));
    }

    public override void Send(SendOrPostCallback d, object state)
    {
        throw new NotImplementedException("Send");
    }
}

It's also possible to change this part:

return task.GetAwaiter().GetResult();

To this:

return task.Result;

In this case, the exception will be propagated to the caller as AggregateException, with AggregateException.InnerException pointing to the original exception from inside the async method.

Community
  • 1
  • 1
noseratio
  • 59,932
  • 34
  • 208
  • 486
  • Do you have a use exemple of your class PumpingSyncContext ? I tried to replace my `AsyncPump.Run(async () => {})` call by `new PumpingSyncContext.Run(async () => {... return true;})` but this cause me an issue with SignalR. The method awaited from my client never stop to await dispite it did finish on the server. I didn't have this behavior using AsyncPump. – bouh Aug 28 '14 at 12:51
  • I found a way to use it but I think I might not do it properly. First, I save the current context. Then A create a new PumpingSyncContext and set it a current context. I call the Run(à method on it. once Run() is finished I set back the initial context. Problem this didn't solve my await issue on the client. To be able to "fix it", I did a `await Task.Delay(1)` as first isntruction inside the Func<> passed to the Run() method. Why is it a fix at all ? ... – bouh Aug 28 '14 at 14:38
  • It may be related to AspNetSynchronizationContext, which was active before the custom one: http://stackoverflow.com/q/23062154 – noseratio Aug 28 '14 at 14:42