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

Implemented the ability to await in STA tests 🎉 #2818

Merged
merged 14 commits into from
May 14, 2018

Conversation

jnm2
Copy link
Contributor

@jnm2 jnm2 commented Apr 21, 2018

Fixes #1200, re: #2774

Whew! This was fun and the collateral was worth doing, but this was a longer time coming than I expected!

The heart and soul of this change is the 8th (last) commit. Its motivating tests are the 6th commit.

The 7th commit took a lot of thought to get right while keeping the complexity down. It refactors AsyncInvocationRegion—which as I've noticed many times since I arrived at this project, had become neither async, an invocation, nor a region 😆—due to the necessity for it to control the invocation itself rather than being handed the return value from the invocation. I did my best to come up with a descriptive name, AsyncToSyncAdapter, since it adapts async execution to synchronous execution.

The preceding commits were things which were all in the critical path, so for simplicity they are in the same PR. They should each be viewed separately.

@rprouse Would it be a good idea for me to do a walkthrough explanation of the 8th commit for posterity, or do #1200 and #2774 explain it well enough?

/cc @sharwell

…synchronization context check

(saving thread check for later when STA synchronization context is added, plus it overlaps with the next commit's tests)
@jnm2 jnm2 force-pushed the jnm2/sta_synchronizationcontext branch from 0c002f0 to 2e5cb68 Compare April 23, 2018 22:15
@rprouse
Copy link
Member

rprouse commented Apr 24, 2018

@jnm2 I am not neglecting this, I just sold a house AND I'm swamped at work right now. I will review when my head is clearer.

@jnm2
Copy link
Contributor Author

jnm2 commented Apr 24, 2018

@rprouse I appreciate that. Don't worry about it! I also have my hands full. I'd rather have your clear-headed review. 😜 And I don't mind having an excuse to do better documentation or write this one up.

(I'm thinking: I've been programming lazily and hoping people will just tell me where my code is hard to understand so that I can improve it. But that may not be fair on you...)

jnm2 added a commit that referenced this pull request Apr 28, 2018
…lResetEventSlim polyfills for net35 performance
@jnm2
Copy link
Contributor Author

jnm2 commented Apr 28, 2018

I'm adding a simple ManualResetEventSlim polyfill in this one, but I built a commit on top of it with full-blown lock-free CountdownEvent and spinning/lazy ManualResetEventSlim to see if that improves the throughput and gets rid of all the performance issues on net35 CI.

After this PR is merged I'll rebase it and start a separate one.

jnm2 added a commit that referenced this pull request Apr 28, 2018
…lResetEventSlim polyfills for net35 performance
Copy link
Member

@rprouse rprouse left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me although it is a lot to digest. I've asked a couple of questions, so I am not approving yet. @ChrisMaddock or @mikkelbu are either of you up to a second review? This is a large change, so the more eyes the better.

}
else
#endif
Guard.ArgumentNotAsyncVoid(code, nameof(code));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we be guarding that code shouldn't be async period?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

code is TestDelegate which is declared as delegate void, so if it's ever async, it's async void. I added a comment to make that clear.

{
if (type.GetTypeInfo().IsGenericType
? type.GetGenericTypeDefinition().FullName == "System.Threading.Tasks.Task`1"
: type.FullName == "System.Threading.Tasks.Task")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about ValueTask?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm thinking of this as the second async PR, and had planned ValueTask along with general awaitable support in the third PR.

@jnm2 jnm2 force-pushed the jnm2/sta_synchronizationcontext branch from 2e5cb68 to 4be14d6 Compare May 3, 2018 03:03
rprouse
rprouse previously approved these changes May 3, 2018
@rprouse
Copy link
Member

rprouse commented May 3, 2018

.NET 3.5 tests hung on AppVeyor, I've kicked off a new build.

@jnm2
Copy link
Contributor Author

jnm2 commented May 5, 2018

Looks good to me although it is a lot to digest. I've asked a couple of questions, so I am not approving yet. @ChrisMaddock or @mikkelbu are either of you up to a second review? This is a large change, so the more eyes the better.

I would like a second one. Rob, from your review, do you think it would benefit from me commenting the code?

@ChrisMaddock
Copy link
Member

I can try and have a look next week - but I know very little about .NET async, so am probably the wrong person for it!

@jnm2
Copy link
Contributor Author

jnm2 commented May 5, 2018

@ChrisMaddock Thanks for being willing to look! I can think of two-way benefits that can come from reviewing even so. You might catch a typo or think of an NUnit async constraint I didn't cover, and this way you have a general sense of where code is and what it's called. And if you find yourself curious about async, I enjoy explaining things like that. (Whether I'm good at explaining it might be another question. 😄)

@jnm2
Copy link
Contributor Author

jnm2 commented May 5, 2018

@ChrisMaddock I wrote it up for you anyway! 😄

All you need to know about async for this PR:

  • When you use await foo, your method returns back to the caller without running the rest of the method.

  • Instead, your method wraps the rest of itself in an Action so that it can ask foo to schedule that Action to run when foo is completed. foo can schedule this however it wishes.

  • When foo is a Task, foo avoids invoking the rest-of-the-method Action directly when it completes because foo might be being completed by a different thread. (Think TaskCompletionSource.SetResult.)
    Instead, foo uses SynchronizationContext.Current.Post to schedule the Action so that the rest of the method executes in the same context¹ as the part of the method before the await. (This is why after awaiting in Windows Forms, the rest of your method executes on the main UI thread instead of executing on the thread that called TaskCompletionSource.SetResult.)

And all you need to know about SynchronizationContext:

  • The default synchronization context doesn't synchronize anything. Everything you Post to it it immediately turns around and gives to ThreadPool.QueueUserWorkItem. This is why if you are on an STA thread without a synchronization context and you await, the rest of your method runs on a random non-STA background thread. This is the problem this PR fixes.

  • ¹ ‘Context’ means whatever you want it to, if you are the implementer of a SynchronizationContext. People ask you to run stuff by calling your implementation of Post. You get to decide if you want to run each action now or later, or eleven times, or not at all, or get a particular thread to do it, etc.

  • To fix the problem, I just implemented a SynchronizationContext which throws everything in a queue. Later, while NUnit's STA worker thread is waiting for the async method to finish, rather than blocking the whole time, the STA worker thread gets busy executing everything in the queue until the test method is finished.

The rest is plumbing: AsyncToSyncAdapter (formerly AsyncInvocationRegion) gets a SingleThreadedTestMessagePumpStrategy and calls WaitForCompletion which is implemented exactly like the Windows Forms and WPF strategies.

And to get the test method to await using our synchronization context instead of the default one, we have to set the synchronization context before invoking the test method.

@jnm2
Copy link
Contributor Author

jnm2 commented May 5, 2018

@rprouse One of these days I'm going to write it up properly in the PR body. 😆

@ChrisMaddock
Copy link
Member

Thanks Joseph, that looks great! I'll try and take a look at this next week. 😄

@jcansdale
Copy link

jcansdale commented May 8, 2018

Hi folks. I thought this might be of interest/relevant:
https://github.com/AArnott/Xunit.StaFact

This uses distinct StaFact, UIFact, WpfFact and WinFormsFact attributes to more fully emulate different application contexts. I wonder if this PR is similar to the UIFact attribute? I haven't had a chance to drill into the details.

The examples given for these attributes are:

[WpfFact]
public async Task WpfFact_OnSTAThread()
{
    Assert.Equal(ApartmentState.STA, Thread.CurrentThread.GetApartmentState());
    await Task.Yield();
    Assert.Equal(ApartmentState.STA, Thread.CurrentThread.GetApartmentState()); // still there
}
[UIFact]
public async Task UIFact_OnSTAThread()
{
    int initialThread = Environment.CurrentManagedThreadId;
    await Task.Yield();
    Assert.Equal(initialThread, Environment.CurrentManagedThreadId);
}

@jnm2
Copy link
Contributor Author

jnm2 commented May 8, 2018

I see this PR solves the same problem UIFact does, while #2776 solves what WinFormsFact and WpfFact do. I took a more hands-off approach. E.g. rather than installing a WindowsFormsSynchronizationContext, it just reacts compatibly to the one you have.

Copy link
Member

@ChrisMaddock ChrisMaddock left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the write-up! I don't think I'm familiar enough with this stuff to notice if there were any glaring errors - but I was able to follow it through. 🙂

@@ -0,0 +1,15 @@
using NUnit.Framework.Constraints;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a header missing here

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 Fixed.

private const string TaskResultProperty = "Result";
private const string VoidTaskResultType = "VoidTaskResult";
private const string SystemAggregateException = "System.AggregateException";
private const string InnerExceptionsProperty = "InnerExceptions";
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are these strings mostly just used the once? Is there a reason not to inline them?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spot on. I'm going to leave it since this code was moved without changing it, and in the next PR when I add support for general C# awaitables (and maybe F# too), I'll be pulling this stuff out anyway.

new ThrowsConstraintAdapter(),
new ThrowsExceptionConstraintAdapter(),
new NormalConstraintAdapter()
};
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like these! 😄

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, isn't this better than what I tried to do the last time? 😆
Oops, I just realized I forgot to cover setups and teardowns!

…synchronization context check

(saving thread check for later when STA synchronization context is added, plus it overlaps with the next commit's tests)
@jnm2
Copy link
Contributor Author

jnm2 commented May 12, 2018

@rprouse, @ChrisMaddock I rebased to fix the missing header Chris found, and then I added the last four commits containing some new simple tests and refactors. As soon as any team member reapproves, I'm happy for this to be merged.

@jcansdale
Copy link

jcansdale commented May 13, 2018

I was getting some strange results when using NUnit to test some related functionality in TerstDriven.Net (see jcansdale/TestDriven.Net-Issues#110 for the gory details).

It turns out the following test fails in NUnit 3.10.1:

[Test]
[Apartment(ApartmentState.STA)]
public async Task ApartmentStateAfterYield()
{
    await Task.Yield();
    Assert.That(Thread.CurrentThread.GetApartmentState(), Is.EqualTo(ApartmentState.STA));
}

This test passes when using the nunit.framework from this PR.

This PR would get a ✔️ from me. Thanks for your work on this @jnm2. I still find the task architecture a little mind bending and your write-ups have been appreciated. 👍

BTW, I think this would make a great blog post and something for the NUnit twitter. What do you reckon @jnm2? 😄

@jnm2
Copy link
Contributor Author

jnm2 commented May 13, 2018

@jcansdale

This PR would get a ✔️ from me. Thanks for your work on this @jnm2. I still find the task architecture a little mind bending and your write-ups have been appreciated. 👍

Thanks! I see you're a member here, which is awesome... I don't know but I don't think there would be a problem with you approving the PR if you wanted?
My explanations are only oversimplifications of what I've absorbed from smarter folks years ago. I want to democratize async. 😁

BTW, I think this would make a great blog post and something for the NUnit twitter. What do you reckon @jnm2? 😄

Haha, I had just offered to help with this. There are at least three more fundamental changes beyond these two which I want to get in for 3.11:

And a sweep of a dozen potentially-related existing issues.

@jnm2
Copy link
Contributor Author

jnm2 commented May 13, 2018

However, @rprouse, don't wait for my my slow pace with these if you want to release 3.11 sooner. Master has been continuously deliverable as far as I know.

Copy link

@jcansdale jcansdale left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tested this build with an async test I was having problems with. This PR makes STA tests behave is a much less surprising way.

Haven't had a chance to review the code itself.

@jcansdale
Copy link

Thanks! I see you're a member here, which is awesome... I don't know but I don't think there would be a problem with you approving the PR if you wanted?

I've approved from an API consumer PoV. I'd love to review the code itself, but work, family etc. 😕

@jnm2
Copy link
Contributor Author

jnm2 commented May 14, 2018

@jcansdale Understandable, no worries. Thanks for your feedback!

@rprouse
Copy link
Member

rprouse commented May 14, 2018

LGTM, let's get this merged 😄

@rprouse rprouse merged commit 5a3d7f8 into master May 14, 2018
@rprouse rprouse deleted the jnm2/sta_synchronizationcontext branch May 14, 2018 15:42
@jnm2
Copy link
Contributor Author

jnm2 commented May 14, 2018

If you'd like to take advantage of this early, look for version 3.11.0-dev-05580 or greater when you merge this with your nuget.config:

<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <packageSources>
    <add key="NUnit MyGet" value="https://www.myget.org/F/nunit/api/v3/index.json" />
  </packageSources>
</configuration>

@Belorus
Copy link

Belorus commented May 27, 2018

Hi @jnm2

I tried package 05600 with your changes.
#2851 - here i get
System.InvalidOperationException : Shutting down SingleThreadedTestSynchronizationContext with work still in the queue.
This behavior makes perfect sense in very strict env where you wish to avoid async void methods anywhere in your code.

It would be really amazing to make a new configurable behavior that executes all delegates in SynchronizaitonContext that are left. This will make testing of async void methods possible.

WDYT ?

@Belorus
Copy link

Belorus commented May 27, 2018

Also i've noticed that

[assembly: Parallelizable(ParallelScope.Fixtures)]
[assembly: Apartment(ApartmentState.STA)]

is 3-5 times slower than just

[assembly: Parallelizable(ParallelScope.Fixtures)]

@CharliePoole
Copy link
Contributor

@Belorus Since there's only one STA, your first example is single-threaded for all practical purposes. If you had some non-STA fixtures, they would run on parallel. Are you sure all your tests need the STA?

@Belorus
Copy link

Belorus commented May 27, 2018

n@CharliePoole Well, what my example needs is a SynchronizationContext that will run all delegates in its queue before letting test finish. This means that all async void methods have finished their work.

This sounds for me as a good behavior for most cases, if you expect people testing async void methods.

From my POV its absolutely legal to create 8 threads each with its own SynchronizationContext. I don't see reason to execute such tests sequentially.

@CharliePoole
Copy link
Contributor

Because STA is an old COM concept it may not be what you want. @jnm2 did all the recent work on synchronization contexts and will probably have to help us out here.

@jnm2
Copy link
Contributor Author

jnm2 commented May 27, 2018

@Belorus Thanks for the questions! I replied about async void over where you initially asked: #2851 (comment).

Also i've noticed that

[assembly: Parallelizable(ParallelScope.Fixtures)]
[assembly: Apartment(ApartmentState.STA)]

is 3-5 times slower than just

[assembly: Parallelizable(ParallelScope.Fixtures)]

I would expect NUnit to be creating eight STA threads for you. So long as nothing COM-sensitive crosses from one thread to another, that's the ideal situation.
(Easy to avoid- don't put anything COM-sensitive into state that is shared between the threads, such as an instance field of a fixture with ParallelScope.Children.)

@jnm2
Copy link
Contributor Author

jnm2 commented May 27, 2018

Also, Charlie is right about STA being an old COM thing. You should only use it if you need to use COM, such as certain Windows UI. If what you really want is for your async test to stay on the same thread, I think the thing that aligns 1:1 with your goal is actually a SingleThreadedSynchronizationContext like the one I posted at #2851 (comment) or my original one at #1200 (comment).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants