Was ManuallyPumpedSynchronizationContext required in Jon Skeet "TimeMachine" async unit test framework?

I just saw a video class from Jon Skeet where he talks about asynchronous methods for testing modules. It was on a paid website, but I found something similar to what it says in my book (just Ctrl + F "15.6. 3. Testing the device asynchronous code").

The full code can be found on his github , but I simplified it for the sake of my question (my code is mainly StockBrokerTest.CalculateNetWorthAsync_AuthenticationFailure_ThrowsDelayed() but with TimeMachine and Advancer inlined operations).

Suppose we have a class for checking failed login (without unit test to simplify the issue):

 public static class LoginTest { private static TaskCompletionSource<Guid?> loginPromise = new TaskCompletionSource<Guid?>(); public static void Main() { Console.WriteLine("== START =="); // Set up var context = new ManuallyPumpedSynchronizationContext(); // Comment this SynchronizationContext.SetSynchronizationContext(context); // Comment this // Run method under test var result = MethodToBeTested(); Debug.Assert(!result.IsCompleted, "Result should not have been completed yet."); // Advancing time Console.WriteLine("Before advance"); loginPromise.SetResult(null); context.PumpAll(); // Comment this Console.WriteLine("After advance"); // Check result Debug.Assert(result.IsFaulted, "Result should have been faulted."); Debug.Assert(result.Exception.InnerException.GetType() == typeof(ArgumentException), $"The exception should have been of type {nameof(ArgumentException)}."); Console.WriteLine("== END =="); Console.ReadLine(); } private static async Task<int> MethodToBeTested() { Console.WriteLine("Before login"); var userId = await Login(); Console.WriteLine("After login"); if (userId == null) { throw new ArgumentException("Bad username or password"); } return userId.GetHashCode(); } private static Task<Guid?> Login() { return loginPromise.Task; } } 

Where is the implementation of ManuallyPumpedSynchronizationContext :

 public sealed class ManuallyPumpedSynchronizationContext : SynchronizationContext { private readonly BlockingCollection<Tuple<SendOrPostCallback, object>> callbacks; public ManuallyPumpedSynchronizationContext() { callbacks = new BlockingCollection<Tuple<SendOrPostCallback, object>>(); } public override void Post(SendOrPostCallback callback, object state) { Console.WriteLine("Post()"); callbacks.Add(Tuple.Create(callback, state)); } public override void Send(SendOrPostCallback d, object state) { throw new NotSupportedException("Synchronous operations not supported on ManuallyPumpedSynchronizationContext"); } public void PumpAll() { Tuple<SendOrPostCallback, object> callback; while(callbacks.TryTake(out callback)) { Console.WriteLine("PumpAll()"); callback.Item1(callback.Item2); } } } 

Output:

 == START == Before login Before advance After login After advance == END == 

My question is: Why do we need a ManuallyPumpedSynchronizationContext ?

Why is the standard SynchronizationContext not enough? The Post() method is not even called (based on the output). I tried to comment out the lines marked with // Comment this , and the result is the same, and the statements are passed.

If I understand correctly what John Skeet says in the video, the SynchronizationContext.Post() method should be called when we encounter await with an await task. But this is not so. What am I missing?

Additional Information

Through my research, I came across this answer . To try this, I changed the implementation of the Login() method to:

 private static Task<Guid?> Login() { // return loginPromise.Task; return Task<Guid?>.Factory.StartNew( () => { Console.WriteLine("Login()"); return null; }, CancellationToken.None, TaskCreationOptions.None, TaskScheduler.FromCurrentSynchronizationContext()); } 

With this modification, the Post() method was actually called. Output:

 == START == Before login Post() Before advance PumpAll() Login() After login After advance == END == 

So, using Jon Skeet TaskCompletionSource , was it not creating ManuallyPumpedSynchronizationContext ?

Note. I think the video that I saw was made only around the release date of C # 5.

+5
source share
3 answers

In this case, SetResult executes its continuation synchronously (directly). This is due to several undocumented details:

  • await will schedule its continuation using the flag TaskContinuationOption.ExecuteSynchronously . When I first discovered this behavior , I reported it as an error . Although I still think it is less unexpected to have asynchronous continuations, there is a valid efficiency argument in favor of synchronous execution.
  • When await captures a SynchronizationContext , it allows synchronous continuations if the current SynchronizationContext is the same instance as the captured SynchronizationContext (reference equality). Again, this is for performance reasons; equality on SyncCtx instances is undefined, but it works well in the real world.

So, you see this behavior because the same SyncCtx that was captured by await in MethodToBeTested set in the SetResult SynchronizationContext.Current line.

A more realistic example will clear the current SyncCtx after calling the system under test. Thus, unit test code does not exist β€œinside” SyncCtx; it provides only SyncCtx for the system under test:

 ... // Set up var context = new ManuallyPumpedSynchronizationContext(); // Comment this SynchronizationContext.SetSynchronizationContext(context); // Comment this // Run method under test var result = MethodToBeTested(); Debug.Assert(!result.IsCompleted, "Result should not have been completed yet."); // Tear down SyncCtx. SynchronizationContext.SetSynchronizationContext(null); // Advancing time ... 

Alternatively, you can pass TaskCreationOptions.RunContinuationsAsynchronously to the TaskCompletionSource<T> constructor. However, note this bug, which is currently present in the .NET Framework, will prevent this from working with full-featured console applications; It only works for .NET Core console applications.

Or, of course, you can simply wrap SetResult in Task.Run :

 Task.Run(() => loginPromise.SetResult(null)).Wait(); 

which forces the continuation in the thread pool thread (without SyncCtx), so the continuation will have to call Post .

As a final note, you can use my AsyncContext type from the AsyncEx library; this is a more flexible custom SynchronizationContext that binds to a specific thread. I originally wrote AsyncContext for use with unit tests. When SUT has asynchronous code, SyncCtx is usually required. In fact, so that xUnit provides its own built-in right to the test infrastructure.

+3
source

I will not discuss what the purpose of this code is, since I have not read the book or all the github link code that you posted. I will just work with the code posted in the current question.

I would say that in the code that you specified, ManuallyPumpedSynchronizationContext not used (regardless of where you run it: in the console application, unit test, user interface application, etc.). The Post method will not be called because there is no synchronization context switch. It has usually been argued that the continuation of await will be Post ed for the captured synchronization context, and this is generally true, but if after the expected method completes, you are still in the same synchronization context - there is no reason to publish anything - you are in the same context and you can simply continue. Here is what happens here. When you call:

 loginPromise.SetResult(null); 

The current context is still ManuallyPumpedSynchronizationContext .

However, if you change it like this:

 SynchronizationContext.SetSynchronizationContext(null); loginPromise.SetResult(null); 

Now that Login() complete, you are no longer in the captured context, so there really will be Post ed for it, so the continuation will be delayed until you name PumpAll .

UPDATE: see @StephenCleary for a more complete explanation of this behavior (there is another factor that is not mentioned in my answer).

+2
source

Because you are executing your code in a console application.
The console application does not have a synchronization context. SynchronizationContext.Current will always be null.

The purpose of ManuallyPumpedSynchronizationContext is to β€œsave” the synchronization context in which the test method is executed and to β€œpump” the result provided by the completed task into the saved context.

In the console application, the saved context is null , so you did not notice the difference

+1
source

Source: https://habr.com/ru/post/1265695/


All Articles