Skip to content
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

BindingException incorrectly wrapping ArgumentException when using an async step #2100

Open
9 of 37 tasks
btvanhooser opened this issue Aug 20, 2020 · 11 comments · May be fixed by #2328
Open
9 of 37 tasks

BindingException incorrectly wrapping ArgumentException when using an async step #2100

btvanhooser opened this issue Aug 20, 2020 · 11 comments · May be fixed by #2328

Comments

@btvanhooser
Copy link

SpecFlow Version:

  • 3.4
  • 3.3
  • 3.1
  • 3.0
  • 2.4
  • 2.3
  • 2.2
  • 2.1
  • 2.0
  • 1.9

Used Test Runner

  • SpecFlow+Runner
  • MSTest
  • NUnit
  • Xunit

Version number: SpecFlow.NUnit: 3.3.74, NUnit: 3.12.0

Project Format of the SpecFlow project

  • Classic project format using packages.config
  • Classic project format using <PackageReference> tags
  • Sdk-style project format

.feature.cs files are generated using

  • SpecFlow.Tools.MsBuild.Generation NuGet package
  • SpecFlowSingleFileGenerator custom tool

Visual Studio Version

  • VS 2019
  • VS 2017
  • VS 2015

Enable SpecFlowSingleFileGenerator Custom Tool option in Visual Studio extension settings

  • Enabled
  • Disabled

Are the latest Visual Studio updates installed?

  • Yes
  • No, I use Visual Studio version <Major>.<Minor>.<Patch>

.NET Framework:

  • >= .NET 4.5
  • before .NET 4.5
  • .NET Core 2.0
  • .NET Core 2.1
  • .NET Core 2.2
  • .NET Core 3.0
  • .NET Core 3.1
  • .NET Core 5.0

Test Execution Method:

  • Visual Studio Test Explorer
  • TFS/VSTS/Azure DevOps – Task – PLEASE SPECIFY THE NAME OF THE TASK
  • Command line – PLEASE SPECIFY THE FULL COMMAND LINE

<SpecFlow> Section in app.config or content of specflow.json

<empty>

Issue Description

While using LINQ in a test project of ours, I found out that I accidentally allowed an ArgumentNullException to be thrown due to an list never being instantiated. The problem was that this was in a step that was using the async/await pattern and return a Task and the exception that made it to the TestExplorer was "BindingException", but with the correct message within it. This was confusing, so while trying to dig in and find the specific reason for that I create a simple synchronous step that just had that same issue and it appropriately recorded an ArgumentNullException on the TestExplorer this time. I've completely isolated this issue with this test project.

Steps to Reproduce

  1. Run the 2 tests in the attached project
  2. Observe the results in the TestExplorer and see the 2 different exceptions for the same issue

Repro Project

Here ya go

@btvanhooser
Copy link
Author

Also, a quick note: we recently went from 3.1.74 to 3.3.74 and I just saw 3.4.3, but I tried all 3 and this issue is present on each one.

@SabotageAndi
Copy link
Contributor

Yeah, the async/await support is not the best in SpecFlow. You will find some issues here on GitHub about it.

@mdalepiane
Copy link

The exception handling code that throws the BindingException is in BindingInvoker, lines 62 to 67. So far I was able to identify the difference between synchronous and asynchronous code using the tests provided by @btvanhooser:

  • When an exception is thrown while invoking a synchronous method it is a TargetInvocationException
  • When an exception is thrown while invoking an asynchronous method it is a ArgumentException

@mdalepiane
Copy link

I opened #2328 with a fix proposal: Change AsyncHelpers.RunSync exception handling to throw a TargetInvocationException when an exception is thrown while executing the async task. This way exceptions thrown by synchronous and asynchronous executions are the same and can be handled by the same code.

If this fix is reasonable I will proceed with other adjustments in this PR.

@tzongithub
Copy link
Contributor

I think the AsyncHelpers.RunSync is a general purpose helper to queue async calls and the additional wrapping with TargetInvocationException does not fit well into the responsibility of this helper method.
Probably we could look into the SynchronousBindingDelegateInvoker to see if we can unify the exception handling for the sync and async binding call?

But to be honest I don't fully understand the root cause of this problem yet. What happens to the "original" TargetInvocationException thrown by bindingDelegate.DynamicInvoke(invokeArgs) if this runs within AsyncHelpers.RunSync?

@mdalepiane
Copy link

@tzongithub comparing both stack traces I think the root cause is that in the asynchronous code the exception is not thrown when DynamicInvoke is called. The DynamicInvoke simply returns a Task and then the exception is thrown when GetResult is called and the inner workings of both methods is different.

Asynchronous code stack trace:

   at System.Linq.ThrowHelper.ThrowArgumentNullException(ExceptionArgument argument)
   at System.Linq.Enumerable.Where[TSource](IEnumerable`1 source, Func`2 predicate)
   at TechTalk.SpecFlow.Specs.StepDefinitions.AsyncExceptionSteps.<WhenSomeBasicAsyncAction>d__2.MoveNext() in C:\git\SpecFlow\Tests\TechTalk.SpecFlow.Specs\StepDefinitions\AsyncExceptionSteps.cs:line 28
   at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
   at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
   at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task)
   at System.Runtime.CompilerServices.TaskAwaiter.GetResult()
   at TechTalk.SpecFlow.Bindings.SynchronousBindingDelegateInvoker.<>c__DisplayClass2_0.<<InvokeBindingDelegateAsync>b__0>d.MoveNext() in C:\git\SpecFlow\TechTalk.SpecFlow\Bindings\SynchronousBindingDelegateInvoker.cs:line 41

Synchronous code stack trace:

   at System.RuntimeMethodHandle.InvokeMethod(Object target, Object[] arguments, Signature sig, Boolean constructor, Boolean wrapExceptions)
   at System.Reflection.RuntimeMethodInfo.Invoke(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
   at System.Delegate.DynamicInvokeImpl(Object[] args)
   at System.Delegate.DynamicInvoke(Object[] args)
   at TechTalk.SpecFlow.Bindings.SynchronousBindingDelegateInvoker.InvokeBindingDelegateSync(Delegate bindingDelegate, Object[] invokeArgs) in C:\git\SpecFlow\TechTalk.SpecFlow\Bindings\SynchronousBindingDelegateInvoker.cs:line 23

About your suggestion I can either:

  1. Move the exception handling code to SynchronousBindingDelegateInvoker.InvokeBindingDelegateAsync, throw a TargetInvocationException in there and let the (unchanged) BindingInvoker handle it;
  2. Or handle both scenarios (sync and async) in SynchronousBindingDelegateInvoker to throw a custom exception and the handle it in BindingInvoker (but I think this would be more intrusive);

What's your opinion?

@tzongithub
Copy link
Contributor

@mdalepiane I debugged the execution and came to the same conclusion: for some reason only the synchronous Delegate.DynamicInvoke call wraps the exception within the delegate method with TargetInvocationException. In case of the async method delegate the framework does not wrap the original exception.

As you pointed out, the exception handling happens in BindingInvoker.InvokeBinding, where the exceptions of type ArgumentException are treated specially by wrapping them into a BindingException with the message "Error calling binding method...". This dates back to the initial commit of SpecFlow here:

catch (ArgumentException ex)

Back then it was possible to distinguish the case when

  • Delegate.DynamicInvoke fails to call the binding method and throws an ArgumentException. This means that there is a problem with the "binding infrastructure" itself, and the method with the "user's code" is not even executed. Hence a BindingException can be thrown.
  • Delegate.DynamicInvoke calls the binding method and the "user's code" throws an exception. This exception is wrapped into a TargetInvocationException by the framework that we need to "unpack" to have a better readable test output.

Now the problem is that we cannot distinguish these two cases anymore in case of async. We simply don't have this information based on the type of the exception.

Wrapping the exception into a TargetInvocationException has a few drawbacks:

  • We don't know which case from the above two happened. We would always turn the exception into a "user's code" exception and the catch with the ArgumentExeption will be never hit anyway.
  • We have another "exception unpacking" for AggregateException. If we wrap an AggregateException into a TargetInvocationException then the 2-level-unpacking won't work.

I'm considering an alternative solution: get rid of the catch case of ArgumentException in https://github.com/SpecFlowOSS/SpecFlow/blob/master/TechTalk.SpecFlow/Bindings/BindingInvoker.cs#L62

  • we cannot simply distinguish this case anymore when using async
  • since the initial commit the finding of the right delegate and parameter conversions have been improved a lot, it is unlikely that we run into a "binding problem" when calling the delegate
  • there were already complaints that the BindingException obfuscates the original error by hiding the stack trace. So it might make more harm than good.

What do you think?
@SabotageAndi any thoughts on this?

@SabotageAndi
Copy link
Contributor

Do we have tests for the wanted behavior? If not, we should write them. When they are green, I am happy with any implementation.

@mdalepiane
Copy link

mdalepiane commented Feb 25, 2021

@tzongithub I tested with a synchronous method throwing an AggregateException there is no 2-level-unpacking because the AggregateException is wrapped in a TargetInvocationException. If this 2-level-unpacking was expected for synchronous calls I think we have another issue at hand.

I believe the following code in InvokeBindingDelegateAsync would still allow us to distinguish an ArgumentException thrown by a binding failure in Delegate.DynamicInvoke. I believe any binding issue would throw an exception in line 5 that would not be wrapped.

 1   protected virtual object InvokeBindingDelegateAsync(Delegate bindingDelegate, object[] invokeArgs)
 2   {
 3       return AsyncHelpers.RunSync(async () =>
 4       {
 5           var r = bindingDelegate.DynamicInvoke(invokeArgs);
 6           if (r is Task t)
 7           {
 8               try
 9               {
10                   await t;
11               }
12               catch (Exception ex)
13               {
14                   throw new TargetInvocationException(ex);
15               }
16           }
17           return r;
18       });
19   }

Any idea on how I can force an ArgumentException when calling Delegate.DynamicInvoke to check this hypothesis?

@tzongithub
Copy link
Contributor

@mdalepiane
Good idea, putting the try-catch around the await might indeed work to distinguish the cases in case of async calls.
Unfortunately I don't know either how to force the DynamicInvoke to fail (on its own behalf). I think we would need a case where the arguments do not match the biding method. But if I simply try with mismatching arguments I get conversion errors or BindingException in an earlier stage of execution (TestExecutionEngine.GetExecuteArguments) and I don't get this far in the execution.
It suspect that this case simply cannot happen in an end2end scenario anymore due to the other improvements in the binding selection and argument transformations. That's why I considered removing the ArgumentException case handling in general to simplify the logic.

The AggregateException handling has been added exactly due to async support here, so most probably it was never really relevant for sync cases. Hence my concern about the 2-level-unpacking might be still valid. I think sync cases might be wrapped with TargetInvocationException while async cases might be wrapped with AggregateException and hence the unpacking works right now.
A potential solution for this could be if you would wrap the exception into an AggregateException, and only if it was not an AggregateException already. The TargetInvocationException we would leave for the sync cases only.

@tzongithub
Copy link
Contributor

Due to time constraints we will try to look into this later in the OSS Iteration 2

@SabotageAndi SabotageAndi added this to To do in OSS Iteration 2 Mar 30, 2021
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
4 participants