-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Initialize GitHubPane and menus asynchronously #1569
Conversation
There is no need to do this on the Main thread.
Call the IShowCurrentPullRequestCommand directly rather than using DTE.
This resolves the, "Logical tree depth exceeded while traversing the tree. This could indicate a cycle in the tree" exception.
The package that hosts GitHubPane mustn't initialize MEF (even on a background thread).
You could do what we do with 15.6 async tool windows and display a caption and a spinner control indicating that you're initializing.
If querying MEF throws an exception to you indicating the cache is out of date, you could display an error on your tool window advising the user of this state so they can restart. |
var componentModel = (IComponentModel)(await GetServiceAsync(typeof(SComponentModel))); | ||
var exports = componentModel.DefaultExportProvider; | ||
|
||
await JoinableTaskFactory.SwitchToMainThreadAsync(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think you should bring back the SwitchToMainThreadAsync
call that you removed. I expect the menuService
is an STA COM object that requires the UI thread.
|
||
sealed protected async override Task InitializeAsync(CancellationToken cancellationToken, IProgress<ServiceProgressData> progress) | ||
{ | ||
vsUIShell = await GetServiceAsync(typeof(SVsUIShell)) as IVsUIShell; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You should use await this.JoinableTaskFactory.SwitchToMainThreadAsync(CancellationToken);
as the first line in your method. Casting the SVsUIShell object to an interface itself generally requires the UI thread, and otherwise you're vulnerable to a deadlock by relying on RPC to get you to the UI thread.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There is an helper method btw that you can use as ServiceProvider.GetGlobalServiceAsync<SVsUIShell, IVsUIShell> that handles this automatically.
vsUIShell = await GetServiceAsync(typeof(SVsUIShell)) as IVsUIShell; | ||
|
||
var menuCommandService = (OleMenuCommandService)(await GetServiceAsync(typeof(IMenuCommandService))); | ||
await InitializeMenusAsync(menuCommandService); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you pass CancellationToken
into this method so that it can cancel async init operations if VS starts to shut down?
{ | ||
if (serviceType == typeof(SVsUIShell)) | ||
{ | ||
return vsUIShell; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Your comment here is interesting. Returning this STA COM object object from a free-threaded method doesn't solve the problem that the caller will very likely cast this to an interface while still on the background thread that your comment suggests it will be on. You mustn't cast COM objects to any interface, or invoke those interfaces, while on a background thread unless you're sure they are free-threaded objects (and I don't think this one is one of those few).
var provider = VisualStudio.Services.GitHubServiceProvider; | ||
var teServiceHolder = provider.GetService<ITeamExplorerServiceHolder>(); | ||
teServiceHolder.ServiceProvider = serviceProvider; | ||
InitializeAsync(serviceProvider).Forget(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If this async method throws, you'll never know.
I find it's best to create an extension method on Task (we call ours FileAndForget()
) that will attach an OnlyOnFaulted continuation (or await) on the Task and file a failure report, or display an error to the user, or whatever... something to draw attention to the failure so it's more diagnosable.
} | ||
var factory = provider.GetService<IViewViewModelFactory>(); | ||
viewModel = provider.ExportProvider.GetExportedValue<IGitHubPaneViewModel>(); | ||
viewModel.InitializeAsync(this).Forget(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same here. As I sometimes (flippantly) say around my own team, everywhere you use Forget()
consider removing the line entirely -- since evidently you don't care whether it succeeds or fails. 😉
|
||
async Task InitializeAsync(IServiceProvider serviceProvider) | ||
{ | ||
// Allow MEF to initialize its cache asynchronously |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would be a good idea to assert that you are on main thread here using ThreadHelper.ThrowIfNotOnUIThread
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The threading rules state async methods should not throw when called on the wrong thread, but rather should simply switch to the thread they require (using await SwitchToMainThreadAsync
or await TaskScheduler.Default
.)
vsUIShell = await GetServiceAsync(typeof(SVsUIShell)) as IVsUIShell; | ||
|
||
var menuCommandService = (OleMenuCommandService)(await GetServiceAsync(typeof(IMenuCommandService))); | ||
await InitializeMenusAsync(menuCommandService); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since menus have to be initialized on main thread, would be a good idea to switch to main thread before calling InitializeMenusAsync.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You could... but then as an async method of its own, it should be sure to switch within the method itself.
|
||
sealed protected async override Task InitializeAsync(CancellationToken cancellationToken, IProgress<ServiceProgressData> progress) | ||
{ | ||
vsUIShell = await GetServiceAsync(typeof(SVsUIShell)) as IVsUIShell; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There is an helper method btw that you can use as ServiceProvider.GetGlobalServiceAsync<SVsUIShell, IVsUIShell> that handles this automatically.
@AArnott @BertanAygun, Thank for the feedback! I'll dig into this properly tomorrow. Just one quick question.
How do I know what VS services it's okay retrieve using I know the |
We don't have a great answer for that. The best we have is "assume all services you query IServiceProvider/IAsyncServicePRovider for require the UI thread for all access unless documentation explicitly states it's a free-threaded object".
We are formulating a better answer to this with the vssdk-analyzers but it's not ready yet. |
Thanks for the tip. Is there an equivalent if this given an I'm guessing I could do something like this (not very pretty): [ImportingConstructor]
FindDte([Import(typeof(SVsServiceProvider))] IServiceProvider sp)
{
var asp = (IAsyncServiceProvider)sp.GetService(
typeof(Microsoft.VisualStudio.Shell.Interop.SAsyncServiceProvider));
dte = (DTE)asp.GetServiceAsync(typeof(DTE)).Result;
} or using the FindDte()
{
dte = await ServiceProvider.GetGlobalServiceAsync<EnvDTE.DTE, EnvDTE.DTE>();
} Is there a better way that's both concise and unit testable? |
Once I have a COM object, is there any way I can interrogate it to see if it's a I've noticed that VS SDK sample code tends to use |
There is not.
I have a blog post on this topic that might help. In VS, services/packages may fail to load (or simply be skipped such as in safe mode) so we try to code defensively, in expecting nulls from When you require a non-null service for your package/service to do anything useful, I throw an informative message by calling Similarly, I advocate for using |
I don't think so, and certainly one that makes it safe to call from a background thread. All @jcansdale You included a snippet where you had an MEF part initialization should be free-threaded. If it has thread affinity, move it to a method that you'll call on the object after importing it. |
We need an async way to obtain the IGitHubPaneViewModel.
@AArnott Thanks for your tips/advice. I've taken note. I've updated this PR with the changes you requested. Any chance you could take another look? |
The problem is my MEF component isn't tied to a particular package that it would be convenient to initialize it from. It could be used by a number of other MEF components. Would something like this be okay? [ImportingConstructor]
FindDte([Import(typeof(SVsServiceProvider))] IServiceProvider sp)
{
InitializeAsync(sp).FileAndForget();
}
async Task InitializeAsync(IServiceProvider sp)
{
await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync();
dte = (DTE)sp.GetService(typeof(DTE));
} (assuming it was able to deal with I'd add a shim that makes |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks great in general. Just a few small changes.
@@ -112,42 +121,39 @@ StatusBar FindSccStatusBar(Window mainWindow) | |||
return contentControl?.Content as StatusBar; | |||
} | |||
|
|||
class RaisePullRequestCommand : ICommand | |||
class UsageTrackingCommand : ICommand |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this intended to be a more general class now? (the rename suggests that it is). If so, should it be moved to a place where it can be reused more easily and made to accept a lambda which identifies the usage model field? This doesn't need to be in this PR though.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I changed it to use the IShowCurrentPullRequestCommand
directly rather than using dte.Commands.Raise
. The problme was I lost the metrics.
I'm thinking it would be a better idea to record the metrics in the command itself? Only issue is, you wouldn't be able to differentiate where the user found the command (not that it matters yet in this case).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'll fix this in another PR. It's working for the moment.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've moved the tracking of NumberOfShowCurrentPullRequest
into the command itself (where I think it belongs). RaisePullRequestCommand
is now obsolete.
pullRequestStatusViewModel.Number = pullRequest.Number; | ||
pullRequestStatusViewModel.Title = pullRequest.Title; | ||
return pullRequestStatusViewModel; | ||
} | ||
|
||
void IncrementNumberOfShowCurrentPullRequest() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Empty method?
@@ -147,7 +148,8 @@ protected override Task InitializeAsync(CancellationToken cancellationToken, IPr | |||
ErrorHandler.Failed(frame.Show()); | |||
} | |||
|
|||
var viewModel = (IGitHubPaneViewModel)((FrameworkElement)pane.Content).DataContext; | |||
var gitHubPane = (GitHubPane)pane; | |||
var viewModel = await gitHubPane.GetViewModelAsync(); | |||
await viewModel.InitializeAsync(pane); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Won't viewModel.InitializeAsync
have already been called by the time GetViewModelAsync
returns?
@@ -131,6 +144,16 @@ public override void OnToolWindowCreated() | |||
UpdateSearchHost(pane?.IsSearchEnabled ?? false, pane?.SearchQuery); | |||
} | |||
|
|||
void ShowInitializing() | |||
{ | |||
View = new Label { Content = "Initializing MEF" }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I know you were asking what to display here. We probably shouldn't display a spinner as it will be really jerky while MEF is initializing. I think the following should suffice:
View = new TextBlock
{
Text = "Initializing...",
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
};
I changed the text to just "Initializing..." as MEF might not mean much to users.
|
||
void ShowError(Exception e) | ||
{ | ||
View = new TextBox { Text = e.ToString() }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should make this TextBox
read-only:
View = new TextBox
{
Text = e.ToString(),
IsReadOnly = true,
};
} | ||
var factory = provider.GetService<IViewViewModelFactory>(); | ||
var viewModel = provider.ExportProvider.GetExportedValue<IGitHubPaneViewModel>(); | ||
viewModel.InitializeAsync(this).Catch(ShowError).Forget(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should await
this here.
GitHubPackage shouldn't be concerned about this.
@jcansdale said:
Be careful how you add that shim. Don't avoid using the JTF type directly from all your code because only by using that type directly do the vs-threading analyzers help keep you on the safe threading path.
That sounds like your MEF part will be unusable for an undefined length of time, and all callers will have to be prepared to deal with that. What I rather do is make
You shouldn't assume your package is loaded anyway when a MEF part is activated, since MEF can activate parts without the VS package ever having loaded (generally speaking). That's why I advocate for the caller initializing the part by invoking |
@@ -38,11 +38,14 @@ public class InlineReviewsPackage : AsyncPackage | |||
var menuService = (IMenuCommandService)(await GetServiceAsync(typeof(IMenuCommandService))); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Here you're casting what is likely an STA COM object to an interface without first ensuring your on the UI thread. Every async method is responsible for ensuring it's on the right thread rather than assuming anything from its caller, so you should add await this.JoinableTaskFactory.SwitchToMainThreadAsync(CancellationToken);
here.
In particular, your caller is AsyncPackage.InitializeAsync
which will be called on a background thread when the package isn't queried for synchronously. And although you use JTF.RunAsync
with a UIThreadNormalPriority
setting, that does not switch the delegate to the UI thread. Like all JTF.RunAsync
overloads, the delegate is executed immediately on the caller's thread. The UIThreadNormalPriority
argument you pass in only influences the priority with which a switch to the UI thread may occur. So you should have a switch to main thread before casting a VS COM service that you don't know is free-threaded.
Alternatively, use await GetServiceAsync<TService, TInterface>()
which will do the cast while on the UI thread automatically, allowing you to remain on a background thread till you actually need to invoke the interface, which I see you do at the bottom after a switch to the main thread. :) This approach would leave this method running on a background thread during MEF's GetExportedValue
method calls that you have below, which can be a huge win for customers when those exports come from an assembly that has not yet loaded.
@@ -59,17 +57,20 @@ void LogVersionInformation() | |||
var menuService = (IMenuCommandService)(await GetServiceAsync(typeof(IMenuCommandService))); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same comment as I made to your other InitializeMenus()
method.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oops, I remember you said. I'm sorry I missed these. 😊
} | ||
|
||
public void Initialize(IServiceProvider serviceProvider) | ||
public Task<IGitHubPaneViewModel> GetViewModelAsync() => viewModelSource.Task; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This looks like it will likely be misused to violate the 3rd threading rule. If someone tries to await this Task within the context of a JTF.Run delegate, you'll quite likely deadlock.
Can you replace your TaskCompletionSource with a JoinableTask instead? Then call InitializeAsync
within a JTF.RunAsync delegate and store the resulting JoinableTask
in your field.
Then this method should return joinableTask.JoinAsync()
(or simply await joinableTask
) to mitigate deadlocks in callers.
We should always cast STA COM services on Main thread.
This avoids the risk deadlock if called within the context of a JTF.Run delegate. https://github.com/Microsoft/vs-threading/blob/master/doc/threading_rules.md#rule-3-if-ever-awaiting-work-that-was-started-earlier-that-work-must-be-joined https://blogs.msdn.microsoft.com/andrewarnottms/2014/05/07/asynchronous-and-multithreaded-programming-within-vs-using-the-joinabletaskfactory/
@AArnott I'm picking up lots here. Plenty of homework to do! I've updated it to use |
That would be handy. Unfortunately it looks like it's a VS 2017 thing.
This isn't such a problem because it has some extra initialization to do anyway before it starts firing events. All callers do is subscribe to those events. |
@@ -83,30 +80,38 @@ public GitHubPane() : base(null) | |||
|
|||
protected override void Initialize() | |||
{ | |||
InitializeAsync().Catch(ShowError).Forget(); | |||
viewModelTask = ThreadHelper.JoinableTaskFactory.RunAsync(InitializeAsync); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you have this class accept an AsyncPackage
in construction and then use the AsyncPackage.JoinableTaskFactory
instance instead of ThreadHelper.JoinableTaskFactory
here?
That way if VS shuts down before this work is done, we won't risk crashing due to arbitrary work going on in background threads.
Rather than retrieving SComponentModel asynchronously, we can simply retrieve IGitHubServiceProvider (which has the dependency on SComponentModel).
@AArnott I've made a couple more updates.
That way as much initialization as possible is done asynchronously.
I don't completely understand what's going on here (I'm guessing each package has a collection of tasks associated with it). Could you expand a little? |
Your changes look good, @jcansdale.
Yes: When threads are still running at the time VS shuts down the CLR's AppDomain, crashes often result. That's why we curtail the problem this way. |
As suggested by @AArnott and @BertanAygun in #1560.
This PR
GitHubPane
initialize MEF on a background threadGitHubPane
without MEF dependencyIGitHubPaneViewModel
viaGetViewModelAsync
TextBox
This PR would make #1560 obsolete.
Reviewers
TextBox
(to allow the user to copy/paste). Is this enough?Please sanity check AsyncMenuPackage(it was crazy and is now gone 😉)Todo
Fixes #1550